Compare commits

..

1 Commits

Author SHA1 Message Date
Cursor Agent
0cdb11a445 fix: prevent empty selector error in SurveyLoadingAnimation
- Add type guard for isWelcomeCardEnabled to ensure it's a boolean
- Add validation before querySelectorAll to prevent empty selector errors
- Add guard in checkMediaLoaded callback for defensive programming

Fixes FORMBRICKS-K3
2026-02-14 15:05:21 +00:00
58 changed files with 337 additions and 835 deletions

View File

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -18,7 +18,6 @@ import { convertDatesInObject } from "@/lib/time";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
import { handleIntegrations } from "./lib/handleIntegrations";
@@ -96,15 +95,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -44,10 +44,13 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -192,7 +190,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

View File

@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { createSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getSurveys } from "./lib/surveys";
export const GET = withV1ApiWrapper({
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
});
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
response: responses.successResponse(surveysWithQuestions),
};
} catch (error) {
if (error instanceof DatabaseError) {

View File

@@ -112,6 +112,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -2036,12 +2036,12 @@ checksums:
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
@@ -2104,7 +2104,6 @@ checksums:
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13

View File

@@ -22,7 +22,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
import { deleteFile } from "@/modules/storage/service";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { ITEMS_PER_PAGE } from "../constants";
@@ -409,10 +408,9 @@ export const getResponseDownloadFile = async (
if (survey.isVerifyEmailEnabled) {
headers.push("Verified Email");
}
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
const jsonData = getResponsesJson(
survey,
resolvedResponses,
responses,
elements,
userAttributes,
hiddenFields,

View File

@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
// Inputs
inputTextColor: { light: _colors["inputTextColor.light"] },
inputBorderRadius: 8,
inputHeight: 20,
inputHeight: 40,
inputFontSize: 14,
inputPaddingX: 8,
inputPaddingY: 8,
inputPaddingX: 16,
inputPaddingY: 16,
inputPlaceholderOpacity: 0.5,
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
@@ -149,42 +149,6 @@ export const STYLE_DEFAULTS: TProjectStyling = {
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
};
/**
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
*
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
*
* When loading v4.6 data the new fields are absent. Without this helper the
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
* colour), causing a visible mismatch. This function derives the new fields
* from the actually-saved legacy fields so the preview and form stay coherent.
*
* Only sets a field when the legacy source exists AND the new field is absent.
*/
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
const light = (key: string): string | undefined =>
(saved[key] as { light?: string } | null | undefined)?.light;
const q = light("questionColor");
const b = light("brandColor");
const i = light("inputColor");
return {
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
...(b &&
!saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
};
};
/**
* Builds a complete TProjectStyling object from a single brand color.
*

View File

@@ -12,18 +12,11 @@ export function validateInputs<T extends ValidationPair<any>[]>(
for (const [value, schema] of pairs) {
const inputValidation = schema.safeParse(value);
if (!inputValidation.success) {
const zodDetails = inputValidation.error.issues
.map((issue) => {
const path = issue?.path?.join(".") ?? "";
return `${path}${issue.message}`;
})
.join("; ");
logger.error(
inputValidation.error,
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
);
throw new ValidationError(`Validation failed: ${zodDetails}`);
throw new ValidationError("Validation failed");
}
parsedData.push(inputValidation.data);
}

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
"advanced_styling_field_height": "Mindesthöhe",
"advanced_styling_field_height": "Höhe",
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
"suggest_colors": "Farben vorschlagen",
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
"theme": "Theme",
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Scales the headline text.",
"advanced_styling_field_headline_weight": "Headline Font Weight",
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
"advanced_styling_field_height": "Minimum Height",
"advanced_styling_field_height": "Height",
"advanced_styling_field_indicator_bg": "Indicator Background",
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
"advanced_styling_field_input_height_description": "Controls the input field height.",
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
"styling_updated_successfully": "Styling updated successfully",
"suggest_colors": "Suggest colors",
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
"theme": "Theme",
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Escala el texto del titular.",
"advanced_styling_field_headline_weight": "Grosor de fuente del titular",
"advanced_styling_field_headline_weight_description": "Hace el texto del titular más ligero o más grueso.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_indicator_bg": "Fondo del indicador",
"advanced_styling_field_indicator_bg_description": "Colorea la porción rellena de la barra.",
"advanced_styling_field_input_border_radius_description": "Redondea las esquinas del campo.",
"advanced_styling_field_input_font_size_description": "Escala el texto escrito en los campos.",
"advanced_styling_field_input_height_description": "Controla la altura mínima del campo de entrada.",
"advanced_styling_field_input_height_description": "Controla la altura del campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Añade espacio a la izquierda y a la derecha.",
"advanced_styling_field_input_padding_y_description": "Añade espacio en la parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenúa el texto de sugerencia del marcador de posición.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Mostrar firma 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo actualizado correctamente",
"suggest_colors": "Sugerir colores",
"suggested_colors_applied_please_save": "Colores sugeridos generados correctamente. Pulsa \"Guardar\" para conservar los cambios.",
"theme": "Tema",
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajuste la taille du texte du titre.",
"advanced_styling_field_headline_weight": "Graisse de police du titre",
"advanced_styling_field_headline_weight_description": "Rend le texte du titre plus léger ou plus gras.",
"advanced_styling_field_height": "Hauteur minimale",
"advanced_styling_field_height": "Hauteur",
"advanced_styling_field_indicator_bg": "Arrière-plan de l'indicateur",
"advanced_styling_field_indicator_bg_description": "Colore la partie remplie de la barre.",
"advanced_styling_field_input_border_radius_description": "Arrondit les coins du champ de saisie.",
"advanced_styling_field_input_font_size_description": "Ajuste la taille du texte saisi dans les champs.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale du champ de saisie.",
"advanced_styling_field_input_height_description": "Contrôle la hauteur du champ de saisie.",
"advanced_styling_field_input_padding_x_description": "Ajoute de l'espace à gauche et à droite.",
"advanced_styling_field_input_padding_y_description": "Ajoute de l'espace en haut et en bas.",
"advanced_styling_field_input_placeholder_opacity_description": "Atténue le texte d'indication du placeholder.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Afficher la signature «Propulsé par Formbricks»",
"styling_updated_successfully": "Style mis à jour avec succès",
"suggest_colors": "Suggérer des couleurs",
"suggested_colors_applied_please_save": "Couleurs suggérées générées avec succès. Appuyez sur «Enregistrer» pour conserver les modifications.",
"theme": "Thème",
"theme_settings_description": "Créez un thème de style pour toutes les enquêtes. Vous pouvez activer un style personnalisé pour chaque enquête."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Átméretezi a címsor szövegét.",
"advanced_styling_field_headline_weight": "Címsor betűvastagsága",
"advanced_styling_field_headline_weight_description": "Vékonyabbá vagy vastagabbá teszi a címsor szövegét.",
"advanced_styling_field_height": "Minimális magasság",
"advanced_styling_field_height": "Magasság",
"advanced_styling_field_indicator_bg": "Jelző háttere",
"advanced_styling_field_indicator_bg_description": "Kiszínezi a sáv kitöltött részét.",
"advanced_styling_field_input_border_radius_description": "Lekerekíti a beviteli mező sarkait.",
"advanced_styling_field_input_font_size_description": "Átméretezi a beviteli mezőkbe beírt szöveget.",
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
"advanced_styling_field_input_height_description": "A beviteli mező magasságát vezérli.",
"advanced_styling_field_input_padding_x_description": "Térközt ad hozzá balra és jobbra.",
"advanced_styling_field_input_padding_y_description": "Térközt ad hozzá fent és lent.",
"advanced_styling_field_input_placeholder_opacity_description": "Elhalványítja a helykitöltő súgószöveget.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Az „A gépházban: Formbricks” aláírás megjelenítése",
"styling_updated_successfully": "A stílus sikeresen frissítve",
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "A javasolt színek sikeresen generálva. Nyomd meg a \"Mentés\" gombot a változtatások véglegesítéséhez.",
"theme": "Téma",
"theme_settings_description": "Stílustéma létrehozása az összes kérdőívhez. Egyéni stílust engedélyezhet minden egyes kérdőívhez."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "見出しテキストのサイズを調整します。",
"advanced_styling_field_headline_weight": "見出しのフォントの太さ",
"advanced_styling_field_headline_weight_description": "見出しテキストを細くまたは太くします。",
"advanced_styling_field_height": "最小の高さ",
"advanced_styling_field_height": "高さ",
"advanced_styling_field_indicator_bg": "インジケーターの背景",
"advanced_styling_field_indicator_bg_description": "バーの塗りつぶし部分に色を付けます。",
"advanced_styling_field_input_border_radius_description": "入力フィールドの角を丸めます。",
"advanced_styling_field_input_font_size_description": "入力フィールド内の入力テキストのサイズを調整します。",
"advanced_styling_field_input_height_description": "入力フィールドの最小の高さを制御します。",
"advanced_styling_field_input_height_description": "入力フィールドの高さを調整します。",
"advanced_styling_field_input_padding_x_description": "左右にスペースを追加します。",
"advanced_styling_field_input_padding_y_description": "上下にスペースを追加します。",
"advanced_styling_field_input_placeholder_opacity_description": "プレースホルダーのヒントテキストを薄くします。",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
"styling_updated_successfully": "スタイルを正常に更新しました",
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Schaalt de koptekst.",
"advanced_styling_field_headline_weight": "Letterdikte kop",
"advanced_styling_field_headline_weight_description": "Maakt koptekst lichter of vetter.",
"advanced_styling_field_height": "Minimale hoogte",
"advanced_styling_field_height": "Hoogte",
"advanced_styling_field_indicator_bg": "Indicatorachtergrond",
"advanced_styling_field_indicator_bg_description": "Kleurt het gevulde deel van de balk.",
"advanced_styling_field_input_border_radius_description": "Rondt de invoerhoeken af.",
"advanced_styling_field_input_font_size_description": "Schaalt de getypte tekst in invoervelden.",
"advanced_styling_field_input_height_description": "Bepaalt de minimale hoogte van het invoerveld.",
"advanced_styling_field_input_height_description": "Bepaalt de hoogte van het invoerveld.",
"advanced_styling_field_input_padding_x_description": "Voegt ruimte toe aan de linker- en rechterkant.",
"advanced_styling_field_input_padding_y_description": "Voegt ruimte toe aan de boven- en onderkant.",
"advanced_styling_field_input_placeholder_opacity_description": "Vervaagt de tijdelijke aanwijzingstekst.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Toon 'Powered by Formbricks' handtekening",
"styling_updated_successfully": "Styling succesvol bijgewerkt",
"suggest_colors": "Kleuren voorstellen",
"suggested_colors_applied_please_save": "Voorgestelde kleuren succesvol gegenereerd. Druk op \"Opslaan\" om de wijzigingen te behouden.",
"theme": "Thema",
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço na parte superior e inferior.",
"advanced_styling_field_input_placeholder_opacity_description": "Esmaece o texto de dica do placeholder.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione \"Salvar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ajusta o tamanho do texto do título.",
"advanced_styling_field_headline_weight": "Peso da fonte do título",
"advanced_styling_field_headline_weight_description": "Torna o texto do título mais leve ou mais negrito.",
"advanced_styling_field_height": "Altura mínima",
"advanced_styling_field_height": "Altura",
"advanced_styling_field_indicator_bg": "Fundo do indicador",
"advanced_styling_field_indicator_bg_description": "Colore a porção preenchida da barra.",
"advanced_styling_field_input_border_radius_description": "Arredonda os cantos do campo.",
"advanced_styling_field_input_font_size_description": "Ajusta o tamanho do texto digitado nos campos.",
"advanced_styling_field_input_height_description": "Controla a altura mínima do campo de entrada.",
"advanced_styling_field_input_height_description": "Controla a altura do campo de entrada.",
"advanced_styling_field_input_padding_x_description": "Adiciona espaço à esquerda e à direita.",
"advanced_styling_field_input_padding_y_description": "Adiciona espaço no topo e na base.",
"advanced_styling_field_input_placeholder_opacity_description": "Atenua o texto de sugestão do placeholder.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Mostrar assinatura 'Powered by Formbricks'",
"styling_updated_successfully": "Estilo atualizado com sucesso",
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressiona \"Guardar\" para manter as alterações.",
"theme": "Tema",
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Scalează textul titlului.",
"advanced_styling_field_headline_weight": "Grosime font titlu",
"advanced_styling_field_headline_weight_description": "Face textul titlului mai subțire sau mai îngroșat.",
"advanced_styling_field_height": "Înălțime minimă",
"advanced_styling_field_height": "Înălțime",
"advanced_styling_field_indicator_bg": "Fundal indicator",
"advanced_styling_field_indicator_bg_description": "Colorează partea umplută a barei.",
"advanced_styling_field_input_border_radius_description": "Rotunjește colțurile câmpurilor de introducere.",
"advanced_styling_field_input_font_size_description": "Scalează textul introdus în câmpuri.",
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a câmpului de introducere.",
"advanced_styling_field_input_height_description": "Controlează înălțimea câmpului de introducere.",
"advanced_styling_field_input_padding_x_description": "Adaugă spațiu la stânga și la dreapta.",
"advanced_styling_field_input_padding_y_description": "Adaugă spațiu deasupra și dedesubt.",
"advanced_styling_field_input_placeholder_opacity_description": "Estompează textul de sugestie din placeholder.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Afișează semnătura „Powered by Formbricks”",
"styling_updated_successfully": "Stilizarea a fost actualizată cu succes",
"suggest_colors": "Sugerează culori",
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apasă pe „Salvează” pentru a păstra modificările.",
"theme": "Temă",
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Масштабирует текст заголовка.",
"advanced_styling_field_headline_weight": "Толщина шрифта заголовка",
"advanced_styling_field_headline_weight_description": "Делает текст заголовка тоньше или жирнее.",
"advanced_styling_field_height": "Минимальная высота",
"advanced_styling_field_height": "Высота",
"advanced_styling_field_indicator_bg": "Фон индикатора",
"advanced_styling_field_indicator_bg_description": "Задаёт цвет заполненной части полосы.",
"advanced_styling_field_input_border_radius_description": "Скругляет углы полей ввода.",
"advanced_styling_field_input_font_size_description": "Масштабирует введённый текст в полях ввода.",
"advanced_styling_field_input_height_description": "Определяет минимальную высоту поля ввода.",
"advanced_styling_field_input_height_description": "Определяет высоту поля ввода.",
"advanced_styling_field_input_padding_x_description": "Добавляет отступы слева и справа.",
"advanced_styling_field_input_padding_y_description": "Добавляет пространство сверху и снизу.",
"advanced_styling_field_input_placeholder_opacity_description": "Делает текст подсказки менее заметным.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
"styling_updated_successfully": "Стили успешно обновлены",
"suggest_colors": "Предложить цвета",
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
"theme": "Тема",
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "Ändrar storleken på rubriken.",
"advanced_styling_field_headline_weight": "Rubrikens teckentjocklek",
"advanced_styling_field_headline_weight_description": "Gör rubriktexten tunnare eller fetare.",
"advanced_styling_field_height": "Minsta höjd",
"advanced_styling_field_height": "Höjd",
"advanced_styling_field_indicator_bg": "Indikatorns bakgrund",
"advanced_styling_field_indicator_bg_description": "Färglägger den fyllda delen av stapeln.",
"advanced_styling_field_input_border_radius_description": "Rundar av hörnen på inmatningsfält.",
"advanced_styling_field_input_font_size_description": "Ändrar storleken på texten i inmatningsfält.",
"advanced_styling_field_input_height_description": "Styr den minsta höjden på inmatningsfältet.",
"advanced_styling_field_input_height_description": "Styr höjden på inmatningsfältet.",
"advanced_styling_field_input_padding_x_description": "Lägger till utrymme till vänster och höger.",
"advanced_styling_field_input_padding_y_description": "Lägger till utrymme upptill och nedtill.",
"advanced_styling_field_input_placeholder_opacity_description": "Tonar ut platshållartexten.",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "Visa 'Powered by Formbricks'-signatur",
"styling_updated_successfully": "Stiluppdatering lyckades",
"suggest_colors": "Föreslå färger",
"suggested_colors_applied_please_save": "Föreslagna färger har skapats. Tryck på \"Spara\" för att spara ändringarna.",
"theme": "Tema",
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning."
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "调整主标题文字大小。",
"advanced_styling_field_headline_weight": "标题字体粗细",
"advanced_styling_field_headline_weight_description": "设置主标题文字的粗细。",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_height": "高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "设置进度条已填充部分的颜色。",
"advanced_styling_field_input_border_radius_description": "设置输入框圆角。",
"advanced_styling_field_input_font_size_description": "调整输入框内文字大小。",
"advanced_styling_field_input_height_description": "设置输入框的最小高度。",
"advanced_styling_field_input_height_description": "控制输入框高度。",
"advanced_styling_field_input_padding_x_description": "增加输入框左右间距。",
"advanced_styling_field_input_padding_y_description": "为输入框上下添加间距。",
"advanced_styling_field_input_placeholder_opacity_description": "调整占位提示文字的透明度。",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
"styling_updated_successfully": "样式更新成功",
"suggest_colors": "推荐颜色",
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
"theme": "主题",
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
},

View File

@@ -2153,12 +2153,12 @@
"advanced_styling_field_headline_size_description": "調整標題文字的大小。",
"advanced_styling_field_headline_weight": "標題字體粗細",
"advanced_styling_field_headline_weight_description": "讓標題文字變細或變粗。",
"advanced_styling_field_height": "最小高度",
"advanced_styling_field_height": "高度",
"advanced_styling_field_indicator_bg": "指示器背景",
"advanced_styling_field_indicator_bg_description": "設定進度條已填滿部分的顏色。",
"advanced_styling_field_input_border_radius_description": "調整輸入框的圓角。",
"advanced_styling_field_input_font_size_description": "調整輸入框內輸入文字的大小。",
"advanced_styling_field_input_height_description": "設定輸入欄位的最小高度。",
"advanced_styling_field_input_height_description": "調整輸入欄位的高度。",
"advanced_styling_field_input_padding_x_description": "在左右兩側增加間距。",
"advanced_styling_field_input_padding_y_description": "在上方和下方增加間距。",
"advanced_styling_field_input_placeholder_opacity_description": "讓提示文字變得更淡。",
@@ -2221,7 +2221,6 @@
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
"styling_updated_successfully": "樣式已成功更新",
"suggest_colors": "建議顏色",
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
"theme": "主題",
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
},

View File

@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", mockQuestions);
validateResponseData([], mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
});
test("should use default language code", () => {
@@ -125,58 +125,25 @@ describe("validateResponseData", () => {
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
test("should validate only fields present in responseData", () => {
test("should validate only present fields when finished is false", () => {
const partialResponseData: TResponseData = { element1: "test" };
const elementsToValidate = [mockElements[0]];
const partialElements = [mockElements[0]];
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, partialResponseData, "en");
validateResponseData(mockBlocks, partialResponseData, "en", false);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
});
test("should never validate elements not in responseData", () => {
const blocksWithTwoElements: TSurveyBlock[] = [
...mockBlocks,
{
id: "block2",
name: "Block 2",
elements: [
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
],
},
];
const allElements = [
...mockElements,
{
id: "element2",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q2" },
required: true,
inputType: "text",
charLimit: { enabled: false },
},
];
const responseDataWithOnlyElement1: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(allElements);
test("should validate all fields when finished is true", () => {
const partialResponseData: TResponseData = { element1: "test" };
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
validateResponseData(mockBlocks, partialResponseData, "en", true);
// Only element1 should be validated, not element2 (even though it's required)
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
[allElements[0]],
responseDataWithOnlyElement1,
"en"
);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
});
});

View File

@@ -9,13 +9,13 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules.
* Only validates elements that have data in responseData - never validates
* all survey elements regardless of completion status.
* Validates response data against survey validation rules
* Handles partial responses (in-progress) by only validating present fields when finished is false
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @param finished - Whether the response is finished (defaults to true for management APIs)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @returns Validation error map keyed by element ID, or null if validation passes
*/
@@ -23,6 +23,7 @@ export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
finished: boolean = true,
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
@@ -41,8 +42,11 @@ export const validateResponseData = (
// Extract elements from blocks
const allElements = getElementsFromBlocks(blocksToUse);
// Always validate only elements that are present in responseData
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
// If response is not finished, only validate elements that are present in the response data
// This prevents "required" errors for fields the user hasn't reached yet
const elementsToValidate = finished
? allElements
: allElements.filter((element) => Object.keys(responseData).includes(element.id));
// Validate selected elements
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);

View File

@@ -15,7 +15,7 @@ import {
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -51,10 +51,7 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
return handleApiError(request, response.error as ApiErrorResponseV2);
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
});
@@ -201,6 +198,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
body.finished,
questionsResponse.data.questions
);
@@ -246,10 +244,7 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
auditLog.newObject = response.data;
}
return responses.successResponse({
...response,
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
});
return responses.successResponse(response);
},
action: "updated",
targetType: "response",

View File

@@ -12,7 +12,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
export const GET = async (request: NextRequest) =>
@@ -44,9 +44,7 @@ export const GET = async (request: NextRequest) =>
environmentResponses.push(...res.data.data);
return responses.successResponse({
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
});
return responses.successResponse({ data: environmentResponses });
},
});
@@ -136,6 +134,7 @@ export const POST = async (request: Request) =>
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
body.finished,
surveyQuestions.data.questions
);

View File

@@ -7,9 +7,10 @@ import { validateInputs } from "@/lib/utils/validate";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getPersonSegmentIds, getSegments } from "./segments";
// Mock the cache functions
vi.mock("@/lib/cache", () => ({
cache: {
withCache: vi.fn(async (fn) => await fn()),
withCache: vi.fn(async (fn) => await fn()), // Just execute the function without caching for tests
},
}));
@@ -29,15 +30,15 @@ vi.mock("@formbricks/database", () => ({
contact: {
findFirst: vi.fn(),
},
$transaction: vi.fn(),
},
}));
// Mock React cache
vi.mock("react", async () => {
const actual = await vi.importActual("react");
return {
...actual,
cache: <T extends (...args: any[]) => any>(fn: T): T => fn,
cache: <T extends (...args: any[]) => any>(fn: T): T => fn, // Return the function with the same type signature
};
});
@@ -96,20 +97,22 @@ describe("segments lib", () => {
});
describe("getPersonSegmentIds", () => {
const mockWhereClause = { AND: [{ environmentId: mockEnvironmentId }, {}] };
beforeEach(() => {
vi.mocked(prisma.segment.findMany).mockResolvedValue(
mockSegmentsData as Prisma.Result<typeof prisma.segment, unknown, "findMany">
);
vi.mocked(segmentFilterToPrismaQuery).mockResolvedValue({
ok: true,
data: { whereClause: mockWhereClause },
data: { whereClause: { AND: [{ environmentId: mockEnvironmentId }, {}] } },
});
});
test("should return person segment IDs successfully", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -125,12 +128,12 @@ describe("segments lib", () => {
});
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
expect(prisma.contact.findFirst).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(result).toEqual(mockSegmentsData.map((s) => s.id));
});
test("should return empty array if no segments exist", async () => {
vi.mocked(prisma.segment.findMany).mockResolvedValue([]);
vi.mocked(prisma.segment.findMany).mockResolvedValue([]); // No segments
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -141,11 +144,10 @@ describe("segments lib", () => {
expect(result).toEqual([]);
expect(segmentFilterToPrismaQuery).not.toHaveBeenCalled();
expect(prisma.$transaction).not.toHaveBeenCalled();
});
test("should return empty array if segments exist but none match", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([null, null]);
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -153,14 +155,16 @@ describe("segments lib", () => {
mockContactUserId,
mockDeviceType
);
expect(result).toEqual([]);
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should call validateInputs with correct parameters", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, { id: mockContactId }]);
vi.mocked(prisma.contact.findFirst).mockResolvedValue({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>);
await getPersonSegmentIds(mockEnvironmentId, mockContactId, mockContactUserId, mockDeviceType);
expect(validateInputs).toHaveBeenCalledWith(
@@ -171,7 +175,14 @@ describe("segments lib", () => {
});
test("should return only matching segment IDs", async () => {
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }, null]);
// First segment matches, second doesn't
vi.mocked(prisma.contact.findFirst)
.mockResolvedValueOnce({ id: mockContactId } as Prisma.Result<
typeof prisma.contact,
unknown,
"findFirst"
>) // First segment matches
.mockResolvedValueOnce(null); // Second segment does not match
const result = await getPersonSegmentIds(
mockEnvironmentId,
@@ -182,66 +193,6 @@ describe("segments lib", () => {
expect(result).toEqual([mockSegmentsData[0].id]);
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(mockSegmentsData.length);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should include segments with no filters as always-matching", async () => {
const segmentsWithEmptyFilters = [
{ id: "segment-no-filter", filters: [] },
{ id: "segment-with-filter", filters: [{}] as TBaseFilter[] },
];
vi.mocked(prisma.segment.findMany).mockResolvedValue(
segmentsWithEmptyFilters as Prisma.Result<typeof prisma.segment, unknown, "findMany">
);
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toContain("segment-no-filter");
expect(result).toContain("segment-with-filter");
expect(segmentFilterToPrismaQuery).toHaveBeenCalledTimes(1);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should skip segments where filter query building fails", async () => {
vi.mocked(segmentFilterToPrismaQuery)
.mockResolvedValueOnce({
ok: true,
data: { whereClause: mockWhereClause },
})
.mockResolvedValueOnce({
ok: false,
error: { type: "bad_request", message: "Invalid filters", details: [] },
});
vi.mocked(prisma.$transaction).mockResolvedValue([{ id: mockContactId }]);
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toEqual(["segment1"]);
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
});
test("should return empty array on unexpected error", async () => {
vi.mocked(prisma.segment.findMany).mockRejectedValue(new Error("Unexpected"));
const result = await getPersonSegmentIds(
mockEnvironmentId,
mockContactId,
mockContactUserId,
mockDeviceType
);
expect(result).toEqual([]);
});
});
});

View File

@@ -37,6 +37,47 @@ export const getSegments = reactCache(
)
);
/**
* Checks if a contact matches a segment using Prisma query
* This leverages native DB types (valueDate, valueNumber) for accurate comparisons
* Device filters are evaluated at query build time using the provided deviceType
*/
const isContactInSegment = async (
contactId: string,
segmentId: string,
filters: TBaseFilters,
environmentId: string,
deviceType: "phone" | "desktop"
): Promise<boolean> => {
// If no filters, segment matches all contacts
if (!filters || filters.length === 0) {
return true;
}
const queryResult = await segmentFilterToPrismaQuery(segmentId, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return false;
}
const { whereClause } = queryResult.data;
// Check if this specific contact matches the segment filters
const matchingContact = await prisma.contact.findFirst({
where: {
id: contactId,
...whereClause,
},
select: { id: true },
});
return matchingContact !== null;
};
export const getPersonSegmentIds = async (
environmentId: string,
contactId: string,
@@ -48,70 +89,23 @@ export const getPersonSegmentIds = async (
const segments = await getSegments(environmentId);
if (!segments || !Array.isArray(segments) || segments.length === 0) {
// fast path; if there are no segments, return an empty array
if (!segments || !Array.isArray(segments)) {
return [];
}
// Phase 1: Build all Prisma where clauses concurrently.
// This converts segment filters into where clauses without per-contact DB queries.
const segmentWithClauses = await Promise.all(
segments.map(async (segment) => {
const filters = segment.filters as TBaseFilters | null;
// Device filters are evaluated at query build time using the provided deviceType
const segmentPromises = segments.map(async (segment) => {
const filters = segment.filters;
const isIncluded = await isContactInSegment(contactId, segment.id, filters, environmentId, deviceType);
return isIncluded ? segment.id : null;
});
if (!filters || filters.length === 0) {
return { segmentId: segment.id, whereClause: {} as Prisma.ContactWhereInput };
}
const results = await Promise.all(segmentPromises);
const queryResult = await segmentFilterToPrismaQuery(segment.id, filters, environmentId, deviceType);
if (!queryResult.ok) {
logger.warn(
{ segmentId: segment.id, environmentId, error: queryResult.error },
"Failed to build Prisma query for segment"
);
return { segmentId: segment.id, whereClause: null };
}
return { segmentId: segment.id, whereClause: queryResult.data.whereClause };
})
);
// Separate segments into: always-match (no filters), needs-DB-check, and failed-to-build
const alwaysMatchIds: string[] = [];
const toCheck: { segmentId: string; whereClause: Prisma.ContactWhereInput }[] = [];
for (const item of segmentWithClauses) {
if (item.whereClause === null) {
continue;
}
if (Object.keys(item.whereClause).length === 0) {
alwaysMatchIds.push(item.segmentId);
} else {
toCheck.push({ segmentId: item.segmentId, whereClause: item.whereClause });
}
}
if (toCheck.length === 0) {
return alwaysMatchIds;
}
// Phase 2: Batch all contact-match checks into a single DB transaction.
// Replaces N individual findFirst queries with one batched round-trip.
const batchResults = await prisma.$transaction(
toCheck.map(({ whereClause }) =>
prisma.contact.findFirst({
where: { id: contactId, ...whereClause },
select: { id: true },
})
)
);
// Phase 3: Collect matching segment IDs
const dbMatchIds = toCheck.filter((_, i) => batchResults[i] !== null).map(({ segmentId }) => segmentId);
return [...alwaysMatchIds, ...dbMatchIds];
return results.filter((id): id is string => id !== null);
} catch (error) {
// Log error for debugging but don't throw to prevent "segments is not iterable" error
logger.warn(
{
environmentId,

View File

@@ -2,26 +2,13 @@ import { NextRequest, userAgent } from "next/server";
import { logger } from "@formbricks/logger";
import { TContactAttributesInput } from "@formbricks/types/contact-attribute";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TJsPersonState } from "@formbricks/types/js";
import { responses } from "@/app/lib/api/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
const handleError = (err: unknown, url: string): { response: Response } => {
if (err instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
}
if (err instanceof ValidationError) {
return { response: responses.badRequestResponse(err.message, undefined, true) };
}
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -136,7 +123,16 @@ export const POST = withV1ApiWrapper({
response: responses.successResponse(responseJson, true),
};
} catch (err) {
return handleError(err, req.url);
if (err instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse(err.resourceType, err.resourceId),
};
}
logger.error({ error: err, url: req.url }, "Error in POST /api/v1/client/[environmentId]/user");
return {
response: responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true),
};
}
},
});

View File

@@ -54,6 +54,7 @@ export const prepareNewSDKAttributeForStorage = (
};
const handleStringType = (value: TRawValue): TAttributeStorageColumns => {
// String type - only use value column
let stringValue: string;
if (value instanceof Date) {

View File

@@ -130,12 +130,7 @@ export const updateAttributes = async (
const messages: TAttributeUpdateMessage[] = [];
const errors: TAttributeUpdateMessage[] = [];
// Coerce boolean values to strings (SDK may send booleans for string attributes)
const coercedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(contactAttributesParam)) {
coercedAttributes[key] = typeof value === "boolean" ? String(value) : value;
}
// Convert email and userId to strings for lookup (they should always be strings, but handle numbers gracefully)
const emailValue =
contactAttributesParam.email === null || contactAttributesParam.email === undefined
? null
@@ -159,7 +154,7 @@ export const updateAttributes = async (
const userIdExists = !!existingUserIdAttribute;
// Remove email and/or userId from attributes if they already exist on another contact
let contactAttributes = { ...coercedAttributes };
let contactAttributes = { ...contactAttributesParam };
// Determine what the final email and userId values will be after this update
// Only consider a value as "submitted" if it was explicitly included in the attributes

View File

@@ -13,14 +13,22 @@ describe("validateAndParseAttributeValue", () => {
}
});
test("rejects number values (SDK must pass actual strings)", () => {
test("converts numbers to string", () => {
const result = validateAndParseAttributeValue(42, "string", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error.code).toBe("string_type_mismatch");
expect(result.error.params.key).toBe("testKey");
expect(result.error.params.type).toBe("number");
expect(formatValidationError(result.error)).toContain("received a number");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("42");
expect(result.parsedValue.valueNumber).toBeNull();
}
});
test("converts Date to ISO string", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = validateAndParseAttributeValue(date, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
expect(result.parsedValue.valueDate).toBeNull();
}
});
});

View File

@@ -27,6 +27,15 @@ export type TAttributeValidationResult =
error: TAttributeValidationError;
};
/**
* Converts any value to a string representation
*/
const convertToString = (value: TRawValue): string => {
if (value instanceof Date) return value.toISOString();
if (typeof value === "number") return String(value);
return value;
};
/**
* Gets a human-readable type name for error messages
*/
@@ -36,28 +45,16 @@ const getTypeName = (value: TRawValue): string => {
};
/**
* Validates and parses a string type attribute.
* Validates and parses a string type attribute
*/
const validateStringType = (value: TRawValue, attributeKey: string): TAttributeValidationResult => {
if (typeof value === "string") {
return {
valid: true,
parsedValue: {
value,
valueNumber: null,
valueDate: null,
},
};
}
return {
valid: false,
error: {
code: "string_type_mismatch",
params: { key: attributeKey, type: getTypeName(value) },
},
};
};
const validateStringType = (value: TRawValue): TAttributeValidationResult => ({
valid: true,
parsedValue: {
value: convertToString(value),
valueNumber: null,
valueDate: null,
},
});
/**
* Validates and parses a number type attribute.
@@ -173,13 +170,13 @@ export const validateAndParseAttributeValue = (
): TAttributeValidationResult => {
switch (expectedDataType) {
case "string":
return validateStringType(value, attributeKey);
return validateStringType(value);
case "number":
return validateNumberType(value, attributeKey);
case "date":
return validateDateType(value, attributeKey);
default:
return validateStringType(value, attributeKey);
return validateStringType(value);
}
};
@@ -188,8 +185,6 @@ export const validateAndParseAttributeValue = (
* Used for API/SDK responses.
*/
const VALIDATION_ERROR_TEMPLATES: Record<string, string> = {
string_type_mismatch:
"Attribute '{key}' expects a string but received a {type}. Pass an actual string value.",
number_type_mismatch:
"Attribute '{key}' expects a number but received a string. Pass an actual number value (e.g., 123 instead of \"123\").",
date_invalid: "Attribute '{key}' expects a valid date. Received: Invalid Date",

View File

@@ -5,14 +5,10 @@ import { getSegment } from "../segments";
import { segmentFilterToPrismaQuery } from "./prisma-query";
const mockQueryRawUnsafe = vi.fn();
const mockFindFirst = vi.fn();
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRawUnsafe: (...args: unknown[]) => mockQueryRawUnsafe(...args),
contactAttribute: {
findFirst: (...args: unknown[]) => mockFindFirst(...args),
},
},
}));
@@ -30,9 +26,7 @@ describe("segmentFilterToPrismaQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: backfill is complete, no un-migrated rows
mockFindFirst.mockResolvedValue(null);
// Fallback path mock: raw SQL returns one matching contact when un-migrated rows exist
// Default mock: number filter raw SQL returns one matching contact
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
});
@@ -151,16 +145,7 @@ describe("segmentFilterToPrismaQuery", () => {
},
},
],
OR: [
{
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gt: 30 },
},
},
},
],
OR: [{ id: { in: ["mock-contact-1"] } }],
});
}
});
@@ -772,12 +757,7 @@ describe("segmentFilterToPrismaQuery", () => {
});
expect(subgroup.AND[0].AND[2]).toStrictEqual({
attributes: {
some: {
attributeKey: { key: "age" },
valueNumber: { gte: 18 },
},
},
id: { in: ["mock-contact-1"] },
});
// Segment inclusion
@@ -1178,23 +1158,10 @@ describe("segmentFilterToPrismaQuery", () => {
},
});
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
const secondSubgroup = whereClause.AND?.[0];
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "loginCount" },
valueNumber: { gt: 5 },
},
},
});
expect(secondSubgroup.AND[1].AND).toContainEqual({
attributes: {
some: {
attributeKey: { key: "purchaseAmount" },
valueNumber: { lte: 1000 },
},
},
id: { in: ["mock-contact-1"] },
});
// Third subgroup (negation operators in OR clause)
@@ -1265,7 +1232,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "purchaseDate", dataType: "date" },
attributeKey: { key: "purchaseDate" },
OR: [
{ valueDate: { lt: new Date(targetDate) } },
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
@@ -1309,7 +1276,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "signupDate", dataType: "date" },
attributeKey: { key: "signupDate" },
OR: [
{ valueDate: { gt: new Date(targetDate) } },
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
@@ -1354,7 +1321,7 @@ describe("segmentFilterToPrismaQuery", () => {
{
attributes: {
some: {
attributeKey: { key: "lastActivityDate", dataType: "date" },
attributeKey: { key: "lastActivityDate" },
OR: [
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
{
@@ -1671,15 +1638,8 @@ describe("segmentFilterToPrismaQuery", () => {
mode: "insensitive",
});
// Number filter uses clean Prisma filter post-backfill
expect(andConditions[1]).toEqual({
attributes: {
some: {
attributeKey: { key: "purchaseCount" },
valueNumber: { gt: 5 },
},
},
});
// Number filter uses raw SQL subquery (transition code) returning contact IDs
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
// Date filter uses OR fallback with 'valueDate' and string 'value'
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");

View File

@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
return {
attributes: {
some: {
attributeKey: { key: contactAttributeKey, dataType: "date" },
attributeKey: { key: contactAttributeKey },
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
},
},
@@ -116,102 +116,59 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
/**
* Builds a Prisma where clause for number attribute filters.
* Uses a clean Prisma query when all rows have valueNumber populated (post-backfill).
* Falls back to a raw SQL subquery for un-migrated rows (valueNumber NULL, value contains numeric string).
* Uses a raw SQL subquery to handle both migrated rows (valueNumber populated)
* and un-migrated rows (valueNumber NULL, value contains numeric string).
* This is transition code for the deferred value backfill.
*
* TODO: After the backfill script has been run and all valueNumber columns are populated,
* remove the un-migrated fallback path entirely.
* revert this to the clean Prisma-only version that queries valueNumber directly.
*/
const buildNumberAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter,
environmentId: string
filter: TSegmentAttributeFilter
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
const { operator } = qualifier;
const numericValue = typeof value === "number" ? value : Number(value);
let valueNumberCondition: Prisma.FloatNullableFilter;
switch (operator) {
case "greaterThan":
valueNumberCondition = { gt: numericValue };
break;
case "greaterEqual":
valueNumberCondition = { gte: numericValue };
break;
case "lessThan":
valueNumberCondition = { lt: numericValue };
break;
case "lessEqual":
valueNumberCondition = { lte: numericValue };
break;
default:
return {};
}
const migratedFilter: Prisma.ContactWhereInput = {
attributes: {
some: {
attributeKey: { key: contactAttributeKey },
valueNumber: valueNumberCondition,
},
},
};
const hasUnmigratedRows = await prisma.contactAttribute.findFirst({
where: {
attributeKey: {
key: contactAttributeKey,
environmentId,
dataType: "number",
},
valueNumber: null,
},
select: { id: true },
});
if (!hasUnmigratedRows) {
return migratedFilter;
}
const sqlOp = SQL_OPERATORS[operator];
const unmigratedMatchingIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
if (!sqlOp) {
return {};
}
const matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
`
SELECT DISTINCT ca."contactId"
FROM "ContactAttribute" ca
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
WHERE cak.key = $1
AND cak."environmentId" = $4
AND cak."dataType" = 'number'
AND ca."valueNumber" IS NULL
AND ca.value ~ $3
AND ca.value::double precision ${sqlOp} $2
AND (
(ca."valueNumber" IS NOT NULL AND ca."valueNumber" ${sqlOp} $2)
OR
(ca."valueNumber" IS NULL AND ca.value ~ $3 AND ca.value::double precision ${sqlOp} $2)
)
`,
contactAttributeKey,
numericValue,
NUMBER_PATTERN_SQL,
environmentId
NUMBER_PATTERN_SQL
);
if (unmigratedMatchingIds.length === 0) {
return migratedFilter;
const contactIds = matchingContactIds.map((r) => r.contactId);
if (contactIds.length === 0) {
// Return an impossible condition so the filter correctly excludes all contacts
return { id: "__NUMBER_FILTER_NO_MATCH__" };
}
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
return {
OR: [migratedFilter, { id: { in: contactIds } }],
};
return { id: { in: contactIds } };
};
/**
* Builds a Prisma where clause from a segment attribute filter
*/
const buildAttributeFilterWhereClause = async (
filter: TSegmentAttributeFilter,
environmentId: string
filter: TSegmentAttributeFilter
): Promise<Prisma.ContactWhereInput> => {
const { root, qualifier, value } = filter;
const { contactAttributeKey } = root;
@@ -258,7 +215,7 @@ const buildAttributeFilterWhereClause = async (
// Handle number operators
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
return await buildNumberAttributeFilterWhereClause(filter);
}
// For string operators, ensure value is a primitive (not an object or array)
@@ -296,8 +253,7 @@ const buildAttributeFilterWhereClause = async (
* Builds a Prisma where clause from a person filter
*/
const buildPersonFilterWhereClause = async (
filter: TSegmentPersonFilter,
environmentId: string
filter: TSegmentPersonFilter
): Promise<Prisma.ContactWhereInput> => {
const { personIdentifier } = filter.root;
@@ -309,7 +265,7 @@ const buildPersonFilterWhereClause = async (
contactAttributeKey: personIdentifier,
},
};
return await buildAttributeFilterWhereClause(personFilter, environmentId);
return await buildAttributeFilterWhereClause(personFilter);
}
return {};
@@ -358,7 +314,6 @@ const buildDeviceFilterWhereClause = (
const buildSegmentFilterWhereClause = async (
filter: TSegmentSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
@@ -382,7 +337,7 @@ const buildSegmentFilterWhereClause = async (
const newPath = new Set(segmentPath);
newPath.add(segmentId);
return processFilters(segment.filters, newPath, environmentId, deviceType);
return processFilters(segment.filters, newPath, deviceType);
};
/**
@@ -391,25 +346,19 @@ const buildSegmentFilterWhereClause = async (
const processSingleFilter = async (
filter: TSegmentFilter,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
const { root } = filter;
switch (root.type) {
case "attribute":
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter);
case "person":
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
case "device":
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
case "segment":
return await buildSegmentFilterWhereClause(
filter as TSegmentSegmentFilter,
segmentPath,
environmentId,
deviceType
);
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
default:
return {};
}
@@ -421,7 +370,6 @@ const processSingleFilter = async (
const processFilters = async (
filters: TBaseFilters,
segmentPath: Set<string>,
environmentId: string,
deviceType?: "phone" | "desktop"
): Promise<Prisma.ContactWhereInput> => {
if (filters.length === 0) return {};
@@ -438,10 +386,10 @@ const processFilters = async (
// Process the resource based on its type
if (isResourceFilter(resource)) {
// If it's a single filter, process it directly
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
whereClause = await processSingleFilter(resource, segmentPath, deviceType);
} else {
// If it's a group of filters, process it recursively
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
whereClause = await processFilters(resource, segmentPath, deviceType);
}
if (Object.keys(whereClause).length === 0) continue;
@@ -484,7 +432,7 @@ export const segmentFilterToPrismaQuery = reactCache(
// Initialize an empty stack for tracking the current evaluation path
const segmentPath = new Set<string>([segmentId]);
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
const filtersWhereClause = await processFilters(filters, segmentPath, deviceType);
const whereClause = {
AND: [baseWhereClause, filtersWhereClause],

View File

@@ -136,48 +136,28 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
const surveyConnect = surveyId ? { surveys: { connect: { id: surveyId } } } : {};
let data: Prisma.SegmentCreateArgs["data"] = {
environmentId,
title,
description,
isPrivate,
filters,
};
if (surveyId) {
data = {
...data,
surveys: {
connect: {
id: surveyId,
},
},
};
}
try {
// Private segments use upsert because auto-save may have already created a
// default (empty-filter) segment via connectOrCreate before the user publishes.
// Without upsert the second create hits the (environmentId, title) unique constraint.
if (isPrivate) {
const segment = await prisma.segment.upsert({
where: {
environmentId_title: {
environmentId,
title,
},
},
create: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
update: {
description,
filters,
...surveyConnect,
},
select: selectSegment,
});
return transformPrismaSegment(segment);
}
const segment = await prisma.segment.create({
data: {
environmentId,
title,
description,
isPrivate,
filters,
...surveyConnect,
},
data,
select: selectSegment,
});

View File

@@ -11,12 +11,7 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling, ZProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling, TSurveyType } from "@formbricks/types/surveys/types";
import { previewSurvey } from "@/app/lib/templates";
import {
COLOR_DEFAULTS,
STYLE_DEFAULTS,
deriveNewFieldsFromLegacy,
getSuggestedColors,
} from "@/lib/styling/constants";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { updateProjectAction } from "@/modules/projects/settings/actions";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
@@ -67,23 +62,11 @@ export const ThemeStyling = ({
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
: {};
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
const form = useForm<TProjectStyling>({
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...cleanSaved },
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
resolver: zodResolver(ZProjectStyling),
});
// Brand color shown in the preview. Only updated when the user triggers
// "Suggest colors", "Save", or "Reset to default" — NOT on every keystroke
// in the brand-color picker. This prevents the loading-spinner / progress
// bar from updating while the user is still picking a colour.
const [previewBrandColor, setPreviewBrandColor] = useState<string>(
(cleanSaved as Partial<TProjectStyling>).brandColor?.light ??
STYLE_DEFAULTS.brandColor?.light ??
COLOR_DEFAULTS.brandColor
);
const [previewSurveyType, setPreviewSurveyType] = useState<TSurveyType>("link");
const [confirmResetStylingModalOpen, setConfirmResetStylingModalOpen] = useState(false);
const [confirmSuggestColorsOpen, setConfirmSuggestColorsOpen] = useState(false);
@@ -101,7 +84,6 @@ export const ThemeStyling = ({
if (updatedProjectResponse?.data) {
form.reset({ ...STYLE_DEFAULTS });
setPreviewBrandColor(STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.styling_updated_successfully"));
router.refresh();
} else {
@@ -118,10 +100,7 @@ export const ThemeStyling = ({
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
}
// Commit brand color to the preview now that all derived colours are in sync.
setPreviewBrandColor(brandColor ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor);
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
toast.success(t("environments.workspace.look.styling_updated_successfully"));
setConfirmSuggestColorsOpen(false);
};
@@ -134,11 +113,7 @@ export const ThemeStyling = ({
});
if (updatedProjectResponse?.data) {
const saved = updatedProjectResponse.data.styling;
form.reset({ ...saved });
setPreviewBrandColor(
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
);
form.reset({ ...updatedProjectResponse.data.styling });
toast.success(t("environments.workspace.look.styling_updated_successfully"));
} else {
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
@@ -274,9 +249,7 @@ export const ThemeStyling = ({
survey={previewSurvey(project.name, t)}
project={{
...project,
styling: form.watch("allowStyleOverwrite")
? { ...form.watch(), brandColor: { light: previewBrandColor } }
: STYLE_DEFAULTS,
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
}}
previewType={previewSurveyType}
setPreviewType={setPreviewSurveyType}

View File

@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
/**
* Resolves a storage URL to an absolute URL.
* - If already absolute, returns as-is
* - If already absolute, returns as-is (backward compatibility for old data)
* - If relative (/storage/...), prepends the appropriate base URL
* @param url The storage URL (relative or absolute)
* @param accessType The access type to determine which base URL to use (defaults to "public")
@@ -163,7 +163,7 @@ export const resolveStorageUrl = (
): string => {
if (!url) return "";
// Already absolute URL - return as-is
// Already absolute URL - return as-is (backward compatibility for old data)
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
@@ -176,41 +176,3 @@ export const resolveStorageUrl = (
return url;
};
// Matches the actual storage URL format: /storage/{id}/{public|private}/{filename...}
const STORAGE_URL_PATTERN = /^\/storage\/[^/]+\/(public|private)\/.+/;
const isStorageUrl = (value: string): boolean => STORAGE_URL_PATTERN.test(value);
export const resolveStorageUrlAuto = (url: string): string => {
if (!isStorageUrl(url)) return url;
const accessType = url.includes("/private/") ? "private" : "public";
return resolveStorageUrl(url, accessType);
};
/**
* Recursively walks an object/array and resolves all relative storage URLs
* Preserves the original structure; skips Date instances and non-object primitives.
*/
export const resolveStorageUrlsInObject = <T>(obj: T): T => {
if (obj === null || obj === undefined) return obj;
if (typeof obj === "string") {
return resolveStorageUrlAuto(obj) as T;
}
if (typeof obj !== "object") return obj;
if (obj instanceof Date) return obj;
if (Array.isArray(obj)) {
return obj.map((item) => resolveStorageUrlsInObject(item)) as T;
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
result[key] = resolveStorageUrlsInObject(value);
}
return result as T;
};

View File

@@ -9,7 +9,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, getSuggestedColors } from "@/lib/styling/constants";
import { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
import { FormStylingSettings } from "@/modules/survey/editor/components/form-styling-settings";
import { LogoSettingsCard } from "@/modules/survey/editor/components/logo-settings-card";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
@@ -68,15 +68,10 @@ export const StylingView = ({
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
: {};
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...projectLegacyFills,
...cleanProject,
...surveyLegacyFills,
...cleanSurvey,
},
});
@@ -99,7 +94,7 @@ export const StylingView = ({
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
}
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
toast.success(t("environments.workspace.look.styling_updated_successfully"));
setConfirmSuggestColorsOpen(false);
};

View File

@@ -20,10 +20,20 @@ export const SurveyLoadingAnimation = ({
const [isMediaLoaded, setIsMediaLoaded] = useState(false); // Tracks if all media are fully loaded
const [isSurveyPackageLoaded, setIsSurveyPackageLoaded] = useState(false); // Tracks if the survey package has been loaded into the DOM
const isReadyToTransition = isMediaLoaded && minTimePassed && isBackgroundLoaded;
const cardId = isWelcomeCardEnabled ? `questionCard--1` : `questionCard-0`;
// Ensure cardId is always a valid string by explicitly checking for boolean type
const cardId =
typeof isWelcomeCardEnabled === "boolean" && isWelcomeCardEnabled
? `questionCard--1`
: `questionCard-0`;
// Function to check if all media elements (images and iframes) within the survey card are loaded
const checkMediaLoaded = useCallback(() => {
// Guard against empty cardId
if (!cardId) {
setIsMediaLoaded(true);
return;
}
const cardElement = document.getElementById(cardId);
const images = cardElement ? Array.from(cardElement.getElementsByTagName("img")) : [];
@@ -37,6 +47,12 @@ export const SurveyLoadingAnimation = ({
useEffect(() => {
if (!isSurveyPackageLoaded) return;
// Guard against empty cardId to prevent "'' is not a valid selector" error
if (!cardId) {
setIsMediaLoaded(true);
return;
}
checkMediaLoaded();
const mediaElements = document.querySelectorAll(`#${cardId} img, #${cardId} iframe`);

View File

@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
tabIndex={0}
aria-controls="options"
aria-expanded={open}
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", {
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
"pointer-events-none": isClearing,
})}>

View File

@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
)}>
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute right-0 top-0 m-2">
<div className="absolute top-0 right-0 m-2">
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
@@ -265,7 +265,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -296,7 +296,7 @@ export const PreviewSurvey = ({
</>
)}
{previewMode === "desktop" && (
<div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-full flex-1 flex-col">
<div className="flex h-8 w-full items-center rounded-t-lg bg-slate-100">
<div className="ml-6 flex space-x-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
@@ -373,7 +373,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}

View File

@@ -96,7 +96,6 @@ test.describe("Survey Styling", async () => {
expect(css).toContain("--fb-input-background-color: #eeeeee");
expect(css).toContain("--fb-input-border-color: #cccccc");
expect(css).toContain("--fb-input-text-color: #024eff");
expect(css).toContain("--fb-input-placeholder-color:");
expect(css).toContain("--fb-input-border-radius: 5px");
expect(css).toContain("--fb-input-height: 50px");
expect(css).toContain("--fb-input-font-size: 16px");

View File

@@ -118,7 +118,7 @@ Scaling:
kubectl get hpa -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
```
{{- else }}
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.deployment.replicas }}` replicas.
HPA is **not enabled**. Your deployment has a fixed number of `{{ .Values.replicaCount }}` replicas.
Manually scale using:
```sh
kubectl scale deployment -n {{ .Release.Namespace }} {{ include "formbricks.name" . }} --replicas=<desired_number>
@@ -127,34 +127,6 @@ Scaling:
---
Pod Disruption Budget:
{{- if .Values.pdb.enabled }}
A PodDisruptionBudget is active to protect against voluntary disruptions.
{{- if not (kindIs "invalid" .Values.pdb.minAvailable) }}
- **Min Available**: `{{ .Values.pdb.minAvailable }}`
{{- end }}
{{- if not (kindIs "invalid" .Values.pdb.maxUnavailable) }}
- **Max Unavailable**: `{{ .Values.pdb.maxUnavailable }}`
{{- end }}
Check PDB status:
```sh
kubectl get pdb -n {{ .Release.Namespace }} {{ include "formbricks.name" . }}
```
{{- if and .Values.autoscaling.enabled (eq (int .Values.autoscaling.minReplicas) 1) }}
WARNING: autoscaling.minReplicas is 1. With minAvailable: 1, the PDB
will block all node drains when only 1 replica is running. Set
autoscaling.minReplicas to at least 2 for proper HA protection.
{{- end }}
{{- else }}
PDB is **not enabled**. Voluntary disruptions (node drains, upgrades) may
take down all pods simultaneously.
{{- end }}
---
External Secrets:
{{- if .Values.externalSecret.enabled }}
External secrets are enabled.

View File

@@ -1,37 +0,0 @@
{{- if .Values.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "pdb.minAvailable and pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "pdb.enabled is true but neither pdb.minAvailable nor pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ template "formbricks.name" . }}
labels:
{{- include "formbricks.labels" . | nindent 4 }}
{{- with .Values.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if .Values.pdb.annotations }}
annotations:
{{- toYaml .Values.pdb.annotations | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.pdb.maxUnavailable }}
{{- end }}
{{- if .Values.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ .Values.pdb.unhealthyPodEvictionPolicy }}
{{- end }}
selector:
matchLabels:
{{- include "formbricks.selectorLabels" . | nindent 6 }}
{{- end }}

View File

@@ -214,42 +214,6 @@ autoscaling:
value: 2
periodSeconds: 60 # Add at most 2 pods every minute
##########################################################
# Pod Disruption Budget (PDB)
#
# Ensures a minimum number of pods remain available during
# voluntary disruptions (node drains, cluster upgrades, etc.).
#
# IMPORTANT:
# - minAvailable and maxUnavailable are MUTUALLY EXCLUSIVE.
# Setting both will cause a helm install/upgrade failure.
# To switch, set the unused one to null in your override file.
# - Accepts an integer (e.g., 1) or a percentage string (e.g., "25%").
# - For PDB to provide real HA protection, ensure
# autoscaling.minReplicas >= 2 (or deployment.replicas >= 2
# if HPA is disabled). With only 1 replica and minAvailable: 1,
# the PDB will block ALL node drains and cluster upgrades.
##########################################################
pdb:
enabled: true
additionalLabels: {}
annotations: {}
# Minimum pods that must remain available during disruptions.
# Set to null and configure maxUnavailable instead if preferred.
minAvailable: 1
# Maximum pods that can be unavailable during disruptions.
# Mutually exclusive with minAvailable — uncomment and set
# minAvailable to null to use this instead.
# maxUnavailable: 1
# Eviction policy for unhealthy pods (Kubernetes 1.27+).
# "IfHealthy" — unhealthy pods count toward the budget (default).
# "AlwaysAllow" — unhealthy pods can always be evicted,
# preventing them from blocking node drain.
# unhealthyPodEvictionPolicy: AlwaysAllow
##########################################################
# Service Configuration
##########################################################

View File

@@ -127,12 +127,12 @@
--fb-input-font-size: 14px;
--fb-input-font-weight: 400;
--fb-input-color: #414b5a;
--fb-input-placeholder-color: var(--fb-input-text-color, var(--fb-input-color));
--fb-input-placeholder-color: var(--fb-input-color);
--fb-input-placeholder-opacity: 0.5;
--fb-input-width: 100%;
--fb-input-height: 20px;
--fb-input-padding-x: 8px;
--fb-input-padding-y: 8px;
--fb-input-height: 40px;
--fb-input-padding-x: 16px;
--fb-input-padding-y: 16px;
--fb-input-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
/* ── Progress Bar ──────────────────────────────────────────────────── */
@@ -244,4 +244,4 @@
#fbjs textarea::-webkit-scrollbar-thumb:hover {
background-color: hsl(215.4 16.3% 46.9% / 0.5);
}
}

View File

@@ -443,39 +443,6 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-button-font-size"]).toBe("1.5rem");
});
test("should derive input-placeholder-color from inputTextColor when set", () => {
const styling: TSurveyStyling = {
...getBaseProjectStyling(),
questionColor: { light: "#AABBCC" },
inputTextColor: { light: "#112233" },
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
// Placeholder should be derived from inputTextColor, not questionColor
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
expect(variables["--fb-placeholder-color"]).toBeDefined();
// Both should be based on inputTextColor (#112233) mixed with white, not questionColor (#AABBCC)
// We can verify by checking the placeholder color doesn't contain the questionColor mix
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
});
test("should derive input-placeholder-color from questionColor when inputTextColor is not set", () => {
const styling: TSurveyStyling = {
...getBaseProjectStyling(),
questionColor: { light: "#AABBCC" },
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
// Placeholder should fall back to questionColor when inputTextColor is not set
expect(variables["--fb-input-placeholder-color"]).toBeDefined();
expect(variables["--fb-placeholder-color"]).toBeDefined();
expect(variables["--fb-input-placeholder-color"]).toBe(variables["--fb-placeholder-color"]);
});
test("should set signature and branding text colors for dark questionColor", () => {
const styling = getBaseProjectStyling({
questionColor: { light: "#202020" }, // A dark color

View File

@@ -111,10 +111,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Backwards-compat: legacy variables still used by some consumers/tests
appendCssVariable("subheading-color", styling.questionColor?.light);
const placeholderBaseColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
if (placeholderBaseColor) {
appendCssVariable("placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
appendCssVariable("input-placeholder-color", mixColor(placeholderBaseColor, "#ffffff", 0.3));
if (styling.questionColor?.light) {
appendCssVariable("placeholder-color", mixColor(styling.questionColor.light, "#ffffff", 0.3));
}
appendCssVariable("border-color", styling.inputBorderColor?.light);
@@ -194,13 +192,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
}
// Buttons (Advanced)
const buttonBg = styling.buttonBgColor?.light ?? styling.brandColor?.light;
let buttonText = styling.buttonTextColor?.light;
if (buttonText === undefined && buttonBg) {
buttonText = isLight(buttonBg) ? "#0f172a" : "#ffffff";
}
appendCssVariable("button-bg-color", buttonBg);
appendCssVariable("button-text-color", buttonText);
appendCssVariable("button-bg-color", styling.buttonBgColor?.light);
appendCssVariable("button-text-color", styling.buttonTextColor?.light);
if (styling.buttonBorderRadius !== undefined)
appendCssVariable("button-border-radius", formatDimension(styling.buttonBorderRadius));
if (styling.buttonHeight !== undefined)
@@ -216,11 +209,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Inputs (Advanced)
appendCssVariable("input-background-color", styling.inputBgColor?.light ?? styling.inputColor?.light);
const inputTextColor = styling.inputTextColor?.light ?? styling.questionColor?.light;
appendCssVariable("input-text-color", inputTextColor);
if (inputTextColor) {
appendCssVariable("input-placeholder-color", mixColor(inputTextColor, "#ffffff", 0.3));
}
appendCssVariable("input-text-color", styling.inputTextColor?.light);
if (styling.inputBorderRadius !== undefined)
appendCssVariable("input-border-radius", formatDimension(styling.inputBorderRadius));
if (styling.inputHeight !== undefined)
@@ -236,8 +225,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
appendCssVariable("input-shadow", styling.inputShadow);
// Options (Advanced)
appendCssVariable("option-bg-color", styling.optionBgColor?.light ?? styling.inputColor?.light);
appendCssVariable("option-label-color", styling.optionLabelColor?.light ?? styling.questionColor?.light);
appendCssVariable("option-bg-color", styling.optionBgColor?.light);
appendCssVariable("option-label-color", styling.optionLabelColor?.light);
if (styling.optionBorderRadius !== undefined)
appendCssVariable("option-border-radius", formatDimension(styling.optionBorderRadius));
if (styling.optionPaddingX !== undefined)
@@ -288,15 +277,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Implicitly set the progress track border radius to the roundness of the card
appendCssVariable("progress-track-border-radius", formatDimension(roundness));
appendCssVariable(
"progress-track-bg-color",
styling.progressTrackBgColor?.light ??
(styling.brandColor?.light ? mixColor(styling.brandColor.light, "#ffffff", 0.8) : undefined)
);
appendCssVariable(
"progress-indicator-bg-color",
styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light
);
appendCssVariable("progress-track-bg-color", styling.progressTrackBgColor?.light);
appendCssVariable("progress-indicator-bg-color", styling.progressIndicatorBgColor?.light);
// Close the #fbjs variable block
cssVariables += "}\n";
@@ -322,7 +304,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n";
if (styling.elementHeadlineFontWeight !== undefined && styling.elementHeadlineFontWeight !== null)
headlineDecls += " font-weight: var(--fb-element-headline-font-weight) !important;\n";
if (styling.elementHeadlineColor?.light || styling.questionColor?.light)
if (styling.elementHeadlineColor?.light)
headlineDecls += " color: var(--fb-element-headline-color) !important;\n";
addRule("#fbjs .label-headline,\n#fbjs .label-headline *", headlineDecls);
@@ -332,7 +314,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
descriptionDecls += " font-size: var(--fb-element-description-font-size) !important;\n";
if (styling.elementDescriptionFontWeight !== undefined && styling.elementDescriptionFontWeight !== null)
descriptionDecls += " font-weight: var(--fb-element-description-font-weight) !important;\n";
if (styling.elementDescriptionColor?.light || styling.questionColor?.light)
if (styling.elementDescriptionColor?.light)
descriptionDecls += " color: var(--fb-element-description-color) !important;\n";
addRule("#fbjs .label-description,\n#fbjs .label-description *", descriptionDecls);
@@ -342,7 +324,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
upperDecls += " font-size: var(--fb-element-upper-label-font-size) !important;\n";
if (styling.elementUpperLabelFontWeight !== undefined && styling.elementUpperLabelFontWeight !== null)
upperDecls += " font-weight: var(--fb-element-upper-label-font-weight) !important;\n";
if (styling.elementUpperLabelColor?.light || styling.questionColor?.light) {
if (styling.elementUpperLabelColor?.light) {
upperDecls += " color: var(--fb-element-upper-label-color) !important;\n";
upperDecls += " opacity: var(--fb-element-upper-label-opacity, 1) !important;\n";
}
@@ -350,10 +332,9 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// --- Buttons ---
let buttonDecls = "";
if (styling.buttonBgColor?.light || styling.brandColor?.light)
if (styling.buttonBgColor?.light)
buttonDecls += " background-color: var(--fb-button-bg-color) !important;\n";
if (styling.buttonTextColor?.light || styling.brandColor?.light)
buttonDecls += " color: var(--fb-button-text-color) !important;\n";
if (styling.buttonTextColor?.light) buttonDecls += " color: var(--fb-button-text-color) !important;\n";
if (styling.buttonBorderRadius !== undefined)
buttonDecls += " border-radius: var(--fb-button-border-radius) !important;\n";
if (styling.buttonHeight !== undefined) buttonDecls += " height: var(--fb-button-height) !important;\n";
@@ -374,11 +355,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// --- Options ---
if (styling.optionBorderRadius !== undefined)
addRule("#fbjs .rounded-option", " border-radius: var(--fb-option-border-radius) !important;\n");
if (styling.optionBgColor?.light || styling.inputColor?.light)
if (styling.optionBgColor?.light)
addRule("#fbjs .bg-option-bg", " background-color: var(--fb-option-bg-color) !important;\n");
let optionLabelDecls = "";
if (styling.optionLabelColor?.light || styling.questionColor?.light)
if (styling.optionLabelColor?.light)
optionLabelDecls += " color: var(--fb-option-label-color) !important;\n";
if (styling.optionFontSize !== undefined)
optionLabelDecls += " font-size: var(--fb-option-font-size) !important;\n";
@@ -404,8 +385,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
addRule("#fbjs .border-input-border", " border-color: var(--fb-input-border-color) !important;\n");
let inputTextDecls = "";
if (styling.inputTextColor?.light || styling.questionColor?.light)
inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
if (styling.inputTextColor?.light) inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
if (styling.inputFontSize !== undefined)
inputTextDecls += " font-size: var(--fb-input-font-size) !important;\n";
addRule("#fbjs .text-input-text", inputTextDecls);

View File

@@ -137,11 +137,6 @@ const checkRequiredField = (
return null;
}
// CTA elements never block progression (informational only)
if (element.type === TSurveyElementTypeEnum.CTA) {
return null;
}
if (element.type === TSurveyElementTypeEnum.Ranking) {
return validateRequiredRanking(value, t);
}

View File

@@ -16,5 +16,5 @@ export type TContactAttribute = z.infer<typeof ZContactAttribute>;
export const ZContactAttributes = z.record(z.string());
export type TContactAttributes = z.infer<typeof ZContactAttributes>;
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number(), z.boolean()]));
export const ZContactAttributesInput = z.record(z.union([z.string(), z.number()]));
export type TContactAttributesInput = z.infer<typeof ZContactAttributesInput>;

View File

@@ -20,9 +20,6 @@ sonar.scm.exclusions.disabled=false
# Encoding of the source code
sonar.sourceEncoding=UTF-8
# Node.js memory limit for JS/TS analysis (in MB)
sonar.javascript.node.maxspace=8192
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,packages/js-core/src/index.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.tsx,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/route.tsx,**/types/**,**/types.ts,**/stories.*,**/*.mock.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/openapi/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts,**/actions.ts,apps/web/modules/ui/components/icons/*,**/*.json,apps/web/vitestSetup.ts,apps/web/tailwind.config.js,apps/web/postcss.config.js,apps/web/next.config.mjs,apps/web/scripts/**,packages/js-core/vitest.setup.ts,packages/js-core/src/index.ts,**/*.mjs,apps/web/modules/auth/lib/mock-data.ts,**/cache.ts,apps/web/app/**/billing-confirmation/**,apps/web/modules/ee/billing/**,apps/web/modules/ee/multi-language-surveys/**,apps/web/modules/email/**,apps/web/modules/integrations/**,apps/web/modules/setup/**/intro/**,apps/web/modules/setup/**/signup/**,apps/web/modules/setup/**/layout.tsx,apps/web/modules/survey/follow-ups/**,apps/web/app/share/**,apps/web/modules/ee/contacts/[contactId]/**,apps/web/modules/ee/contacts/components/**,apps/web/modules/ee/two-factor-auth/**,apps/web/lib/slack/**,apps/web/lib/notion/**,apps/web/lib/googleSheet/**,apps/web/app/api/google-sheet/**,apps/web/app/api/billing/**,apps/web/lib/airtable/**,apps/web/app/api/v1/integrations/**,apps/web/lib/env.ts,**/instrumentation-node.ts,**/cache/**,**/*.svg,apps/web/modules/ui/components/icons/**,apps/web/modules/ui/components/table/**,packages/survey-ui/**/*.stories.*