mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-20 10:09:20 -06:00
Compare commits
14 Commits
feat/hub-i
...
4.7.3-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a32e60441 | ||
|
|
2eaa098602 | ||
|
|
e5393997c2 | ||
|
|
0c47a6eb61 | ||
|
|
b96bc8809b | ||
|
|
3b6b804a68 | ||
|
|
5b776b04b1 | ||
|
|
e3f3516fa7 | ||
|
|
91486f3dc9 | ||
|
|
8b9179bfcc | ||
|
|
1b337aeac3 | ||
|
|
cd64848cc5 | ||
|
|
42411694a7 | ||
|
|
721d972901 |
@@ -21,6 +21,7 @@ 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[] = [];
|
||||
@@ -256,10 +257,16 @@ const processElementResponse = (
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
return element.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.map((choice) => resolveStorageUrlAuto(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);
|
||||
};
|
||||
|
||||
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
|
||||
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl);
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ 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";
|
||||
@@ -95,12 +96,15 @@ 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,
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
|
||||
/**
|
||||
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
overlay: environmentData.project.overlay,
|
||||
placement: environmentData.project.placement,
|
||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||
styling: environmentData.project.styling,
|
||||
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
id: environmentData.project.organization.id,
|
||||
billing: environmentData.project.organization.billing,
|
||||
},
|
||||
surveys: transformedSurveys,
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,13 +44,10 @@ const validateResponse = (
|
||||
...responseUpdateInput.data,
|
||||
};
|
||||
|
||||
const isFinished = responseUpdateInput.finished ?? false;
|
||||
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
mergedData,
|
||||
responseUpdateInput.language ?? response.language ?? "en",
|
||||
isFinished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.response),
|
||||
response: responses.successResponse({
|
||||
...result.response,
|
||||
data: resolveStorageUrlsInObject(result.response.data),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
responseUpdate.finished,
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updated),
|
||||
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
createResponseWithQuotaEvaluation,
|
||||
getResponses,
|
||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(allResponses),
|
||||
response: responses.successResponse(
|
||||
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
responseInput.finished,
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
}),
|
||||
response: responses.successResponse(
|
||||
resolveStorageUrlsInObject({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.survey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ 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({
|
||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -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: f4da6d7ecd26e3fa75cfea03abb60c00
|
||||
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
|
||||
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: b704fc67e805223992c811d6f86a9c00
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
||||
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,6 +2104,7 @@ 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
|
||||
|
||||
@@ -22,6 +22,7 @@ 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";
|
||||
@@ -408,9 +409,10 @@ 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,
|
||||
responses,
|
||||
resolvedResponses,
|
||||
elements,
|
||||
userAttributes,
|
||||
hiddenFields,
|
||||
|
||||
@@ -118,10 +118,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Inputs
|
||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||
inputBorderRadius: 8,
|
||||
inputHeight: 40,
|
||||
inputHeight: 20,
|
||||
inputFontSize: 14,
|
||||
inputPaddingX: 16,
|
||||
inputPaddingY: 16,
|
||||
inputPaddingX: 8,
|
||||
inputPaddingY: 8,
|
||||
inputPlaceholderOpacity: 0.5,
|
||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
@@ -149,6 +149,42 @@ 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.
|
||||
*
|
||||
|
||||
@@ -12,11 +12,18 @@ 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");
|
||||
throw new ValidationError(`Validation failed: ${zodDetails}`);
|
||||
}
|
||||
parsedData.push(inputValidation.data);
|
||||
}
|
||||
|
||||
@@ -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": "Höhe",
|
||||
"advanced_styling_field_height": "Mindesthö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": "Steuert die Höhe des Eingabefelds.",
|
||||
"advanced_styling_field_input_height_description": "Legt die Mindesthöhe des Eingabefelds fest.",
|
||||
"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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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": "Height",
|
||||
"advanced_styling_field_height": "Minimum 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 input field height.",
|
||||
"advanced_styling_field_input_height_description": "Controls the minimum height of the input field.",
|
||||
"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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"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 del campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla la altura mínima 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
"advanced_styling_field_height": "Hauteur minimale",
|
||||
"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 du champ de saisie.",
|
||||
"advanced_styling_field_input_height_description": "Contrôle la hauteur minimale 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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": "Magasság",
|
||||
"advanced_styling_field_height": "Minimális 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ő magasságát vezérli.",
|
||||
"advanced_styling_field_input_height_description": "A beviteli mező minimális magasságát szabályozza.",
|
||||
"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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "「Powered by Formbricks」署名を表示",
|
||||
"styling_updated_successfully": "スタイルを正常に更新しました",
|
||||
"suggest_colors": "カラーを提案",
|
||||
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには「保存」を押してください。",
|
||||
"theme": "テーマ",
|
||||
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
|
||||
},
|
||||
|
||||
@@ -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": "Hoogte",
|
||||
"advanced_styling_field_height": "Minimale 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 hoogte van het invoerveld.",
|
||||
"advanced_styling_field_input_height_description": "Bepaalt de minimale 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"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 do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
"advanced_styling_field_height": "Altura mínima",
|
||||
"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 do campo de entrada.",
|
||||
"advanced_styling_field_input_height_description": "Controla a altura mínima 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
"advanced_styling_field_height": "Înălțime minimă",
|
||||
"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 câmpului de introducere.",
|
||||
"advanced_styling_field_input_height_description": "Controlează înălțimea minimă a 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "Показывать подпись «Работает на Formbricks»",
|
||||
"styling_updated_successfully": "Стили успешно обновлены",
|
||||
"suggest_colors": "Предложить цвета",
|
||||
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажми «Сохранить», чтобы применить изменения.",
|
||||
"theme": "Тема",
|
||||
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
|
||||
},
|
||||
|
||||
@@ -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": "Höjd",
|
||||
"advanced_styling_field_height": "Minsta 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 höjden på inmatningsfältet.",
|
||||
"advanced_styling_field_input_height_description": "Styr den minsta 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,6 +2221,7 @@
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -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,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "显示“Powered by Formbricks”标识",
|
||||
"styling_updated_successfully": "样式更新成功",
|
||||
"suggest_colors": "推荐颜色",
|
||||
"suggested_colors_applied_please_save": "已成功生成推荐配色。请点击“保存”以保留更改。",
|
||||
"theme": "主题",
|
||||
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
|
||||
},
|
||||
|
||||
@@ -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,6 +2221,7 @@
|
||||
"show_powered_by_formbricks": "顯示「Powered by Formbricks」標記",
|
||||
"styling_updated_successfully": "樣式已成功更新",
|
||||
"suggest_colors": "建議顏色",
|
||||
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「儲存」以保存變更。",
|
||||
"theme": "主題",
|
||||
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
|
||||
},
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData([], mockResponseData, "en", true, mockQuestions);
|
||||
validateResponseData([], mockResponseData, "en", mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
|
||||
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
|
||||
@@ -105,15 +105,15 @@ describe("validateResponseData", () => {
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", true, mockQuestions);
|
||||
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
|
||||
|
||||
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return null when both blocks and questions are empty", () => {
|
||||
expect(validateResponseData([], mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", true, [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", true, null)).toBeNull();
|
||||
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
|
||||
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
|
||||
});
|
||||
|
||||
test("should use default language code", () => {
|
||||
@@ -125,25 +125,58 @@ describe("validateResponseData", () => {
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
|
||||
});
|
||||
|
||||
test("should validate only present fields when finished is false", () => {
|
||||
test("should validate only fields present in responseData", () => {
|
||||
const partialResponseData: TResponseData = { element1: "test" };
|
||||
const partialElements = [mockElements[0]];
|
||||
const elementsToValidate = [mockElements[0]];
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, partialResponseData, "en", false);
|
||||
validateResponseData(mockBlocks, partialResponseData, "en");
|
||||
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(partialElements, partialResponseData, "en");
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(elementsToValidate, partialResponseData, "en");
|
||||
});
|
||||
|
||||
test("should validate all fields when finished is true", () => {
|
||||
const partialResponseData: TResponseData = { element1: "test" };
|
||||
mockGetElementsFromBlocks.mockReturnValue(mockElements);
|
||||
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);
|
||||
mockValidateBlockResponses.mockReturnValue({});
|
||||
|
||||
validateResponseData(mockBlocks, partialResponseData, "en", true);
|
||||
validateResponseData(blocksWithTwoElements, responseDataWithOnlyElement1, "en");
|
||||
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, partialResponseData, "en");
|
||||
// Only element1 should be validated, not element2 (even though it's required)
|
||||
expect(mockValidateBlockResponses).toHaveBeenCalledWith(
|
||||
[allElements[0]],
|
||||
responseDataWithOnlyElement1,
|
||||
"en"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
* Handles partial responses (in-progress) by only validating present fields when finished is false
|
||||
* Validates response data against survey validation rules.
|
||||
* Only validates elements that have data in responseData - never validates
|
||||
* all survey elements regardless of completion status.
|
||||
*
|
||||
* @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,7 +23,6 @@ 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
|
||||
@@ -42,11 +41,8 @@ export const validateResponseData = (
|
||||
// Extract elements from blocks
|
||||
const allElements = getElementsFromBlocks(blocksToUse);
|
||||
|
||||
// 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));
|
||||
// Always validate only elements that are present in responseData
|
||||
const elementsToValidate = allElements.filter((element) => Object.keys(responseData).includes(element.id));
|
||||
|
||||
// Validate selected elements
|
||||
const errorMap = validateBlockResponses(elementsToValidate, responseData, languageCode);
|
||||
|
||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
@@ -51,7 +51,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
||||
return handleApiError(request, response.error as ApiErrorResponseV2);
|
||||
}
|
||||
|
||||
return responses.successResponse(response);
|
||||
return responses.successResponse({
|
||||
...response,
|
||||
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -198,7 +201,6 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
questionsResponse.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
questionsResponse.data.questions
|
||||
);
|
||||
|
||||
@@ -244,7 +246,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
auditLog.newObject = response.data;
|
||||
}
|
||||
|
||||
return responses.successResponse(response);
|
||||
return responses.successResponse({
|
||||
...response,
|
||||
data: { ...response.data, data: resolveStorageUrlsInObject(response.data.data) },
|
||||
});
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "response",
|
||||
|
||||
@@ -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 { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
@@ -44,7 +44,9 @@ export const GET = async (request: NextRequest) =>
|
||||
|
||||
environmentResponses.push(...res.data.data);
|
||||
|
||||
return responses.successResponse({ data: environmentResponses });
|
||||
return responses.successResponse({
|
||||
data: environmentResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) })),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -134,7 +136,6 @@ export const POST = async (request: Request) =>
|
||||
surveyQuestions.data.blocks,
|
||||
body.data,
|
||||
body.language ?? "en",
|
||||
body.finished,
|
||||
surveyQuestions.data.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -2,13 +2,26 @@ 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 } from "@formbricks/types/errors";
|
||||
import { ResourceNotFoundError, ValidationError } 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(
|
||||
{},
|
||||
@@ -123,16 +136,7 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.successResponse(responseJson, true),
|
||||
};
|
||||
} catch (err) {
|
||||
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),
|
||||
};
|
||||
return handleError(err, req.url);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -13,22 +13,14 @@ describe("validateAndParseAttributeValue", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("converts numbers to string", () => {
|
||||
test("rejects number values (SDK must pass actual strings)", () => {
|
||||
const result = validateAndParseAttributeValue(42, "string", "testKey");
|
||||
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();
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,15 +27,6 @@ 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
|
||||
*/
|
||||
@@ -45,16 +36,28 @@ const getTypeName = (value: TRawValue): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and parses a string type attribute
|
||||
* Validates and parses a string type attribute.
|
||||
*/
|
||||
const validateStringType = (value: TRawValue): TAttributeValidationResult => ({
|
||||
valid: true,
|
||||
parsedValue: {
|
||||
value: convertToString(value),
|
||||
valueNumber: null,
|
||||
valueDate: null,
|
||||
},
|
||||
});
|
||||
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) },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates and parses a number type attribute.
|
||||
@@ -170,13 +173,13 @@ export const validateAndParseAttributeValue = (
|
||||
): TAttributeValidationResult => {
|
||||
switch (expectedDataType) {
|
||||
case "string":
|
||||
return validateStringType(value);
|
||||
return validateStringType(value, attributeKey);
|
||||
case "number":
|
||||
return validateNumberType(value, attributeKey);
|
||||
case "date":
|
||||
return validateDateType(value, attributeKey);
|
||||
default:
|
||||
return validateStringType(value);
|
||||
return validateStringType(value, attributeKey);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,6 +188,8 @@ 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",
|
||||
|
||||
@@ -5,10 +5,14 @@ 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),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,7 +30,9 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mock: number filter raw SQL returns one matching contact
|
||||
// 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
|
||||
mockQueryRawUnsafe.mockResolvedValue([{ contactId: "mock-contact-1" }]);
|
||||
});
|
||||
|
||||
@@ -145,7 +151,16 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
OR: [{ id: { in: ["mock-contact-1"] } }],
|
||||
OR: [
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "age" },
|
||||
valueNumber: { gt: 30 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -757,7 +772,12 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
});
|
||||
|
||||
expect(subgroup.AND[0].AND[2]).toStrictEqual({
|
||||
id: { in: ["mock-contact-1"] },
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "age" },
|
||||
valueNumber: { gte: 18 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Segment inclusion
|
||||
@@ -1158,10 +1178,23 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Second subgroup (numeric operators - now use raw SQL subquery returning contact IDs)
|
||||
// Second subgroup (numeric operators - uses clean Prisma filter post-backfill)
|
||||
const secondSubgroup = whereClause.AND?.[0];
|
||||
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||
id: { in: ["mock-contact-1"] },
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "loginCount" },
|
||||
valueNumber: { gt: 5 },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(secondSubgroup.AND[1].AND).toContainEqual({
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "purchaseAmount" },
|
||||
valueNumber: { lte: 1000 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Third subgroup (negation operators in OR clause)
|
||||
@@ -1232,7 +1265,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "purchaseDate" },
|
||||
attributeKey: { key: "purchaseDate", dataType: "date" },
|
||||
OR: [
|
||||
{ valueDate: { lt: new Date(targetDate) } },
|
||||
{ valueDate: null, value: { lt: new Date(targetDate).toISOString() } },
|
||||
@@ -1276,7 +1309,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "signupDate" },
|
||||
attributeKey: { key: "signupDate", dataType: "date" },
|
||||
OR: [
|
||||
{ valueDate: { gt: new Date(targetDate) } },
|
||||
{ valueDate: null, value: { gt: new Date(targetDate).toISOString() } },
|
||||
@@ -1321,7 +1354,7 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
{
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "lastActivityDate" },
|
||||
attributeKey: { key: "lastActivityDate", dataType: "date" },
|
||||
OR: [
|
||||
{ valueDate: { gte: new Date(startDate), lte: new Date(endDate) } },
|
||||
{
|
||||
@@ -1638,8 +1671,15 @@ describe("segmentFilterToPrismaQuery", () => {
|
||||
mode: "insensitive",
|
||||
});
|
||||
|
||||
// Number filter uses raw SQL subquery (transition code) returning contact IDs
|
||||
expect(andConditions[1]).toEqual({ id: { in: ["mock-contact-1"] } });
|
||||
// Number filter uses clean Prisma filter post-backfill
|
||||
expect(andConditions[1]).toEqual({
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: "purchaseCount" },
|
||||
valueNumber: { gt: 5 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Date filter uses OR fallback with 'valueDate' and string 'value'
|
||||
expect((andConditions[2] as unknown as any).attributes.some.OR[0].valueDate).toHaveProperty("gte");
|
||||
|
||||
@@ -107,7 +107,7 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
||||
return {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: { key: contactAttributeKey },
|
||||
attributeKey: { key: contactAttributeKey, dataType: "date" },
|
||||
OR: [{ valueDate: dateCondition }, { valueDate: null, value: stringDateCondition }],
|
||||
},
|
||||
},
|
||||
@@ -116,59 +116,102 @@ const buildDateAttributeFilterWhereClause = (filter: TSegmentAttributeFilter): P
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for number attribute filters.
|
||||
* 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.
|
||||
* 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).
|
||||
*
|
||||
* TODO: After the backfill script has been run and all valueNumber columns are populated,
|
||||
* revert this to the clean Prisma-only version that queries valueNumber directly.
|
||||
* remove the un-migrated fallback path entirely.
|
||||
*/
|
||||
const buildNumberAttributeFilterWhereClause = async (
|
||||
filter: TSegmentAttributeFilter
|
||||
filter: TSegmentAttributeFilter,
|
||||
environmentId: string
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
const { operator } = qualifier;
|
||||
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
const sqlOp = SQL_OPERATORS[operator];
|
||||
|
||||
if (!sqlOp) {
|
||||
return {};
|
||||
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 matchingContactIds = await prisma.$queryRawUnsafe<{ contactId: string }[]>(
|
||||
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 }[]>(
|
||||
`
|
||||
SELECT DISTINCT ca."contactId"
|
||||
FROM "ContactAttribute" ca
|
||||
JOIN "ContactAttributeKey" cak ON ca."attributeKeyId" = cak.id
|
||||
WHERE cak.key = $1
|
||||
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)
|
||||
)
|
||||
AND cak."environmentId" = $4
|
||||
AND cak."dataType" = 'number'
|
||||
AND ca."valueNumber" IS NULL
|
||||
AND ca.value ~ $3
|
||||
AND ca.value::double precision ${sqlOp} $2
|
||||
`,
|
||||
contactAttributeKey,
|
||||
numericValue,
|
||||
NUMBER_PATTERN_SQL
|
||||
NUMBER_PATTERN_SQL,
|
||||
environmentId
|
||||
);
|
||||
|
||||
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__" };
|
||||
if (unmigratedMatchingIds.length === 0) {
|
||||
return migratedFilter;
|
||||
}
|
||||
|
||||
return { id: { in: contactIds } };
|
||||
const contactIds = unmigratedMatchingIds.map((r) => r.contactId);
|
||||
|
||||
return {
|
||||
OR: [migratedFilter, { id: { in: contactIds } }],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause from a segment attribute filter
|
||||
*/
|
||||
const buildAttributeFilterWhereClause = async (
|
||||
filter: TSegmentAttributeFilter
|
||||
filter: TSegmentAttributeFilter,
|
||||
environmentId: string
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root, qualifier, value } = filter;
|
||||
const { contactAttributeKey } = root;
|
||||
@@ -215,7 +258,7 @@ const buildAttributeFilterWhereClause = async (
|
||||
|
||||
// Handle number operators
|
||||
if (["greaterThan", "greaterEqual", "lessThan", "lessEqual"].includes(operator)) {
|
||||
return await buildNumberAttributeFilterWhereClause(filter);
|
||||
return await buildNumberAttributeFilterWhereClause(filter, environmentId);
|
||||
}
|
||||
|
||||
// For string operators, ensure value is a primitive (not an object or array)
|
||||
@@ -253,7 +296,8 @@ const buildAttributeFilterWhereClause = async (
|
||||
* Builds a Prisma where clause from a person filter
|
||||
*/
|
||||
const buildPersonFilterWhereClause = async (
|
||||
filter: TSegmentPersonFilter
|
||||
filter: TSegmentPersonFilter,
|
||||
environmentId: string
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { personIdentifier } = filter.root;
|
||||
|
||||
@@ -265,7 +309,7 @@ const buildPersonFilterWhereClause = async (
|
||||
contactAttributeKey: personIdentifier,
|
||||
},
|
||||
};
|
||||
return await buildAttributeFilterWhereClause(personFilter);
|
||||
return await buildAttributeFilterWhereClause(personFilter, environmentId);
|
||||
}
|
||||
|
||||
return {};
|
||||
@@ -314,6 +358,7 @@ const buildDeviceFilterWhereClause = (
|
||||
const buildSegmentFilterWhereClause = async (
|
||||
filter: TSegmentSegmentFilter,
|
||||
segmentPath: Set<string>,
|
||||
environmentId: string,
|
||||
deviceType?: "phone" | "desktop"
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
const { root } = filter;
|
||||
@@ -337,7 +382,7 @@ const buildSegmentFilterWhereClause = async (
|
||||
const newPath = new Set(segmentPath);
|
||||
newPath.add(segmentId);
|
||||
|
||||
return processFilters(segment.filters, newPath, deviceType);
|
||||
return processFilters(segment.filters, newPath, environmentId, deviceType);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -346,19 +391,25 @@ 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);
|
||||
return await buildAttributeFilterWhereClause(filter as TSegmentAttributeFilter, environmentId);
|
||||
case "person":
|
||||
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter);
|
||||
return await buildPersonFilterWhereClause(filter as TSegmentPersonFilter, environmentId);
|
||||
case "device":
|
||||
return buildDeviceFilterWhereClause(filter as TSegmentDeviceFilter, deviceType);
|
||||
case "segment":
|
||||
return await buildSegmentFilterWhereClause(filter as TSegmentSegmentFilter, segmentPath, deviceType);
|
||||
return await buildSegmentFilterWhereClause(
|
||||
filter as TSegmentSegmentFilter,
|
||||
segmentPath,
|
||||
environmentId,
|
||||
deviceType
|
||||
);
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
@@ -370,6 +421,7 @@ const processSingleFilter = async (
|
||||
const processFilters = async (
|
||||
filters: TBaseFilters,
|
||||
segmentPath: Set<string>,
|
||||
environmentId: string,
|
||||
deviceType?: "phone" | "desktop"
|
||||
): Promise<Prisma.ContactWhereInput> => {
|
||||
if (filters.length === 0) return {};
|
||||
@@ -386,10 +438,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, deviceType);
|
||||
whereClause = await processSingleFilter(resource, segmentPath, environmentId, deviceType);
|
||||
} else {
|
||||
// If it's a group of filters, process it recursively
|
||||
whereClause = await processFilters(resource, segmentPath, deviceType);
|
||||
whereClause = await processFilters(resource, segmentPath, environmentId, deviceType);
|
||||
}
|
||||
|
||||
if (Object.keys(whereClause).length === 0) continue;
|
||||
@@ -432,7 +484,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, deviceType);
|
||||
const filtersWhereClause = await processFilters(filters, segmentPath, environmentId, deviceType);
|
||||
|
||||
const whereClause = {
|
||||
AND: [baseWhereClause, filtersWhereClause],
|
||||
|
||||
@@ -136,28 +136,48 @@ export const createSegment = async (segmentCreateInput: TSegmentCreateInput): Pr
|
||||
|
||||
const { description, environmentId, filters, isPrivate, surveyId, title } = segmentCreateInput;
|
||||
|
||||
let data: Prisma.SegmentCreateArgs["data"] = {
|
||||
environmentId,
|
||||
title,
|
||||
description,
|
||||
isPrivate,
|
||||
filters,
|
||||
};
|
||||
|
||||
if (surveyId) {
|
||||
data = {
|
||||
...data,
|
||||
surveys: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const surveyConnect = surveyId ? { 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,
|
||||
data: {
|
||||
environmentId,
|
||||
title,
|
||||
description,
|
||||
isPrivate,
|
||||
filters,
|
||||
...surveyConnect,
|
||||
},
|
||||
select: selectSegment,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,12 @@ 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 { STYLE_DEFAULTS, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import {
|
||||
COLOR_DEFAULTS,
|
||||
STYLE_DEFAULTS,
|
||||
deriveNewFieldsFromLegacy,
|
||||
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";
|
||||
@@ -62,11 +67,23 @@ export const ThemeStyling = ({
|
||||
? Object.fromEntries(Object.entries(savedStyling).filter(([, v]) => v != null))
|
||||
: {};
|
||||
|
||||
const legacyFills = deriveNewFieldsFromLegacy(cleanSaved);
|
||||
|
||||
const form = useForm<TProjectStyling>({
|
||||
defaultValues: { ...STYLE_DEFAULTS, ...cleanSaved },
|
||||
defaultValues: { ...STYLE_DEFAULTS, ...legacyFills, ...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);
|
||||
@@ -84,6 +101,7 @@ 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 {
|
||||
@@ -100,7 +118,10 @@ export const ThemeStyling = ({
|
||||
form.setValue(key as keyof TProjectStyling, value, { shouldDirty: true });
|
||||
}
|
||||
|
||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||
// 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"));
|
||||
setConfirmSuggestColorsOpen(false);
|
||||
};
|
||||
|
||||
@@ -113,7 +134,11 @@ export const ThemeStyling = ({
|
||||
});
|
||||
|
||||
if (updatedProjectResponse?.data) {
|
||||
form.reset({ ...updatedProjectResponse.data.styling });
|
||||
const saved = updatedProjectResponse.data.styling;
|
||||
form.reset({ ...saved });
|
||||
setPreviewBrandColor(
|
||||
saved?.brandColor?.light ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
|
||||
);
|
||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedProjectResponse);
|
||||
@@ -249,7 +274,9 @@ export const ThemeStyling = ({
|
||||
survey={previewSurvey(project.name, t)}
|
||||
project={{
|
||||
...project,
|
||||
styling: form.watch("allowStyleOverwrite") ? form.watch() : STYLE_DEFAULTS,
|
||||
styling: form.watch("allowStyleOverwrite")
|
||||
? { ...form.watch(), brandColor: { light: previewBrandColor } }
|
||||
: STYLE_DEFAULTS,
|
||||
}}
|
||||
previewType={previewSurveyType}
|
||||
setPreviewType={setPreviewSurveyType}
|
||||
|
||||
@@ -151,7 +151,7 @@ export const getErrorResponseFromStorageError = (
|
||||
|
||||
/**
|
||||
* Resolves a storage URL to an absolute URL.
|
||||
* - If already absolute, returns as-is (backward compatibility for old data)
|
||||
* - If already absolute, returns as-is
|
||||
* - 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 (backward compatibility for old data)
|
||||
// Already absolute URL - return as-is
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
return url;
|
||||
}
|
||||
@@ -176,3 +176,41 @@ 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;
|
||||
};
|
||||
|
||||
@@ -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, getSuggestedColors } from "@/lib/styling/constants";
|
||||
import { STYLE_DEFAULTS, deriveNewFieldsFromLegacy, 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,10 +68,15 @@ 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,
|
||||
},
|
||||
});
|
||||
@@ -94,7 +99,7 @@ export const StylingView = ({
|
||||
form.setValue(key as keyof TSurveyStyling, value, { shouldDirty: true });
|
||||
}
|
||||
|
||||
toast.success(t("environments.workspace.look.styling_updated_successfully"));
|
||||
toast.success(t("environments.workspace.look.suggested_colors_applied_please_save"));
|
||||
setConfirmSuggestColorsOpen(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
|
||||
tabIndex={0}
|
||||
aria-controls="options"
|
||||
aria-expanded={open}
|
||||
className={cn("flex h-full w-full cursor-pointer items-center justify-end bg-white pr-2", {
|
||||
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
|
||||
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
|
||||
"pointer-events-none": isClearing,
|
||||
})}>
|
||||
|
||||
@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
|
||||
)}>
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
|
||||
Preview
|
||||
</p>
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<div className="absolute right-0 top-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 top-5 left-5">
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo
|
||||
environmentId={environment.id}
|
||||
@@ -296,7 +296,7 @@ export const PreviewSurvey = ({
|
||||
</>
|
||||
)}
|
||||
{previewMode === "desktop" && (
|
||||
<div className="flex h-full flex-1 flex-col">
|
||||
<div className="flex h-full w-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 top-5 left-5">
|
||||
<div className="absolute left-5 top-5">
|
||||
{!styling.isLogoHidden && (
|
||||
<ClientLogo
|
||||
environmentId={environment.id}
|
||||
|
||||
@@ -96,6 +96,7 @@ 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");
|
||||
|
||||
@@ -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-color);
|
||||
--fb-input-placeholder-color: var(--fb-input-text-color, var(--fb-input-color));
|
||||
--fb-input-placeholder-opacity: 0.5;
|
||||
--fb-input-width: 100%;
|
||||
--fb-input-height: 40px;
|
||||
--fb-input-padding-x: 16px;
|
||||
--fb-input-padding-y: 16px;
|
||||
--fb-input-height: 20px;
|
||||
--fb-input-padding-x: 8px;
|
||||
--fb-input-padding-y: 8px;
|
||||
--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);
|
||||
}
|
||||
}
|
||||
@@ -443,6 +443,39 @@ 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
|
||||
|
||||
@@ -111,8 +111,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
// Backwards-compat: legacy variables still used by some consumers/tests
|
||||
appendCssVariable("subheading-color", styling.questionColor?.light);
|
||||
|
||||
if (styling.questionColor?.light) {
|
||||
appendCssVariable("placeholder-color", mixColor(styling.questionColor.light, "#ffffff", 0.3));
|
||||
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));
|
||||
}
|
||||
|
||||
appendCssVariable("border-color", styling.inputBorderColor?.light);
|
||||
@@ -192,8 +194,13 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
}
|
||||
|
||||
// Buttons (Advanced)
|
||||
appendCssVariable("button-bg-color", styling.buttonBgColor?.light);
|
||||
appendCssVariable("button-text-color", styling.buttonTextColor?.light);
|
||||
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);
|
||||
if (styling.buttonBorderRadius !== undefined)
|
||||
appendCssVariable("button-border-radius", formatDimension(styling.buttonBorderRadius));
|
||||
if (styling.buttonHeight !== undefined)
|
||||
@@ -209,7 +216,11 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
|
||||
// Inputs (Advanced)
|
||||
appendCssVariable("input-background-color", styling.inputBgColor?.light ?? styling.inputColor?.light);
|
||||
appendCssVariable("input-text-color", styling.inputTextColor?.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));
|
||||
}
|
||||
if (styling.inputBorderRadius !== undefined)
|
||||
appendCssVariable("input-border-radius", formatDimension(styling.inputBorderRadius));
|
||||
if (styling.inputHeight !== undefined)
|
||||
@@ -225,8 +236,8 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
appendCssVariable("input-shadow", styling.inputShadow);
|
||||
|
||||
// Options (Advanced)
|
||||
appendCssVariable("option-bg-color", styling.optionBgColor?.light);
|
||||
appendCssVariable("option-label-color", styling.optionLabelColor?.light);
|
||||
appendCssVariable("option-bg-color", styling.optionBgColor?.light ?? styling.inputColor?.light);
|
||||
appendCssVariable("option-label-color", styling.optionLabelColor?.light ?? styling.questionColor?.light);
|
||||
if (styling.optionBorderRadius !== undefined)
|
||||
appendCssVariable("option-border-radius", formatDimension(styling.optionBorderRadius));
|
||||
if (styling.optionPaddingX !== undefined)
|
||||
@@ -277,8 +288,15 @@ 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);
|
||||
appendCssVariable("progress-indicator-bg-color", styling.progressIndicatorBgColor?.light);
|
||||
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
|
||||
);
|
||||
|
||||
// Close the #fbjs variable block
|
||||
cssVariables += "}\n";
|
||||
@@ -304,7 +322,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)
|
||||
if (styling.elementHeadlineColor?.light || styling.questionColor?.light)
|
||||
headlineDecls += " color: var(--fb-element-headline-color) !important;\n";
|
||||
addRule("#fbjs .label-headline,\n#fbjs .label-headline *", headlineDecls);
|
||||
|
||||
@@ -314,7 +332,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)
|
||||
if (styling.elementDescriptionColor?.light || styling.questionColor?.light)
|
||||
descriptionDecls += " color: var(--fb-element-description-color) !important;\n";
|
||||
addRule("#fbjs .label-description,\n#fbjs .label-description *", descriptionDecls);
|
||||
|
||||
@@ -324,7 +342,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) {
|
||||
if (styling.elementUpperLabelColor?.light || styling.questionColor?.light) {
|
||||
upperDecls += " color: var(--fb-element-upper-label-color) !important;\n";
|
||||
upperDecls += " opacity: var(--fb-element-upper-label-opacity, 1) !important;\n";
|
||||
}
|
||||
@@ -332,9 +350,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
||||
|
||||
// --- Buttons ---
|
||||
let buttonDecls = "";
|
||||
if (styling.buttonBgColor?.light)
|
||||
if (styling.buttonBgColor?.light || styling.brandColor?.light)
|
||||
buttonDecls += " background-color: var(--fb-button-bg-color) !important;\n";
|
||||
if (styling.buttonTextColor?.light) buttonDecls += " color: var(--fb-button-text-color) !important;\n";
|
||||
if (styling.buttonTextColor?.light || styling.brandColor?.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";
|
||||
@@ -355,11 +374,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)
|
||||
if (styling.optionBgColor?.light || styling.inputColor?.light)
|
||||
addRule("#fbjs .bg-option-bg", " background-color: var(--fb-option-bg-color) !important;\n");
|
||||
|
||||
let optionLabelDecls = "";
|
||||
if (styling.optionLabelColor?.light)
|
||||
if (styling.optionLabelColor?.light || styling.questionColor?.light)
|
||||
optionLabelDecls += " color: var(--fb-option-label-color) !important;\n";
|
||||
if (styling.optionFontSize !== undefined)
|
||||
optionLabelDecls += " font-size: var(--fb-option-font-size) !important;\n";
|
||||
@@ -385,7 +404,8 @@ 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) inputTextDecls += " color: var(--fb-input-text-color) !important;\n";
|
||||
if (styling.inputTextColor?.light || styling.questionColor?.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);
|
||||
|
||||
@@ -137,6 +137,11 @@ 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user