Compare commits

..

3 Commits

Author SHA1 Message Date
Johannes a37ad0cd66 adds setting toggle to not change default behaviour for all existing in-product surveys 2026-04-13 20:18:46 +02:00
Johannes 175775c96e Merge branch 'main' of https://github.com/formbricks/formbricks into fix/survey-font-family-inherit
Made-with: Cursor
2026-04-13 18:47:32 +02:00
Dhruwang 05be68b714 feat: inherit host page font-family in surveys package
Allow embedded surveys to automatically use the host page's font instead
of hardcoding Inter. This enables custom font support without any
additional configuration.
2026-03-25 11:15:02 +05:30
37 changed files with 863 additions and 95 deletions
@@ -75,7 +75,10 @@ export const ProjectSettings = ({
organizationId,
data: {
...data,
styling: fullStyling,
styling: {
...fullStyling,
isPageFontInheritedByDefault: true,
},
config: { channel, industry },
teamIds: data.teamIds,
},
@@ -112,7 +115,11 @@ export const ProjectSettings = ({
const form = useForm<TProjectUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: { light: defaultBrandColor },
},
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
+4
View File
@@ -1711,6 +1711,8 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/use_page_font: 327c0c42a128a99af13510c24c813cf8
environments/surveys/edit/use_page_font_description: ffb511ce099a5ca25832228a85e2b4d8
environments/surveys/edit/validate_id_duplicate: f88ec35a9bd4921fb096817b9263b59a
environments/surveys/edit/validate_id_empty: 3ee25d429ed5ca9e047f9aee95496323
environments/surveys/edit/validate_id_invalid_chars: 50239938a408c04b02d77b8cd096d767
@@ -2224,6 +2226,8 @@ checksums:
environments/workspace/look/suggested_colors_applied_please_save: a440b8e29a327822a94d9bbf8c52e2ed
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
environments/workspace/look/use_page_font_for_new_surveys: 327c0c42a128a99af13510c24c813cf8
environments/workspace/look/use_page_font_for_new_surveys_description: 010ae8817cc6c9b2aa17ba804e6ff393
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
environments/workspace/tags/add_tag: 2cfa04ceea966149f2b5d40d9c131141
environments/workspace/tags/count: 9c5848662eb8024ddf360f7e4001a968
+1
View File
@@ -93,6 +93,7 @@ const _colors = getSuggestedColors(DEFAULT_BRAND_COLOR);
*/
export const STYLE_DEFAULTS: TProjectStyling = {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: _colors["brandColor.light"] },
questionColor: { light: _colors["questionColor.light"] },
inputColor: { light: _colors["inputColor.light"] },
+69 -5
View File
@@ -7,6 +7,7 @@ describe("Styling Utilities", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: false,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -20,13 +21,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey does not overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -40,13 +45,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns survey styling when both project and survey allow style overwrite", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -60,13 +69,17 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(survey.styling);
expect(getStyling(project, survey)).toEqual({
...survey.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey styling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -76,13 +89,17 @@ describe("Styling Utilities", () => {
styling: undefined,
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling when project allows style overwrite but survey overwriteThemeStyling is undefined", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: "#000000",
highlightBorderColor: "#000000",
},
@@ -95,6 +112,53 @@ describe("Styling Utilities", () => {
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toBe(project.styling);
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("keeps survey font preferences even when theme overwrite is disabled", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
brandColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: false,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
});
});
test("inherits workspace default page-font setting when survey does not specify it", () => {
const project: TJsEnvironmentStateProject = {
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: "#000000",
},
} as unknown as TJsEnvironmentStateProject;
const survey: TJsEnvironmentStateSurvey = {
styling: {
overwriteThemeStyling: false,
},
} as unknown as TJsEnvironmentStateSurvey;
expect(getStyling(project, survey)).toEqual({
...project.styling,
isPageFontInherited: true,
});
});
});
+19 -3
View File
@@ -1,20 +1,36 @@
import { TJsEnvironmentStateProject, TJsEnvironmentStateSurvey } from "@formbricks/types/js";
export const getStyling = (project: TJsEnvironmentStateProject, survey: TJsEnvironmentStateSurvey) => {
const resolvedIsPageFontInherited =
survey.styling?.isPageFontInherited ?? project.styling.isPageFontInheritedByDefault ?? false;
const getFontOverrides = () => ({
isPageFontInherited: resolvedIsPageFontInherited,
...(survey.styling?.fontFamily !== undefined ? { fontFamily: survey.styling.fontFamily } : {}),
});
// allow style overwrite is disabled from the project
if (!project.styling.allowStyleOverwrite) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// survey style overwrite is enabled
return survey.styling;
return {
...survey.styling,
isPageFontInherited: resolvedIsPageFontInherited,
};
}
return project.styling;
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Oberes Label",
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"use_page_font": "Seitenschriftart verwenden",
"use_page_font_description": "Verwende die Schriftart der Host-App oder Website für diese Umfrage.",
"validate_id_duplicate": "{type}-ID existiert bereits in Fragen, versteckten Feldern oder Variablen.",
"validate_id_empty": "Bitte gib eine {type}-ID ein.",
"validate_id_invalid_chars": "{type}-ID ist nicht erlaubt. Bitte verwende nur alphanumerische Zeichen, Bindestriche oder Unterstriche.",
@@ -2340,7 +2342,9 @@
"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."
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren.",
"use_page_font_for_new_surveys": "Seitenschriftart verwenden",
"use_page_font_for_new_surveys_description": "Wende die Schriftart der Host-App oder Website auf alle Umfragen in diesem Workspace an."
},
"tags": {
"add": "Hinzufügen",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"use_page_font": "Use page font",
"use_page_font_description": "Use the host app or website font for this survey.",
"validate_id_duplicate": "{type} ID already exists in questions, hidden fields, or variables.",
"validate_id_empty": "Please enter a {type} ID.",
"validate_id_invalid_chars": "{type} ID is not allowed. Please use only alphanumeric characters, hyphens, or underscores.",
@@ -2340,7 +2342,9 @@
"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."
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey.",
"use_page_font_for_new_surveys": "Use page font",
"use_page_font_for_new_surveys_description": "Apply the host app or website font across surveys in this workspace."
},
"tags": {
"add": "Add",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"use_page_font": "Usar fuente de la página",
"use_page_font_description": "Utiliza la fuente de la aplicación o sitio web anfitrión para esta encuesta.",
"validate_id_duplicate": "El ID de {type} ya existe en preguntas, campos ocultos o variables.",
"validate_id_empty": "Por favor, introduce un ID de {type}.",
"validate_id_invalid_chars": "El ID de {type} no está permitido. Por favor, utiliza solo caracteres alfanuméricos, guiones o guiones bajos.",
@@ -2340,7 +2342,9 @@
"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."
"theme_settings_description": "Crea un tema de estilo para todas las encuestas. Puedes activar el estilo personalizado para cada encuesta.",
"use_page_font_for_new_surveys": "Usar fuente de la página",
"use_page_font_for_new_surveys_description": "Aplica la fuente de la aplicación o sitio web anfitrión en todas las encuestas de este espacio de trabajo."
},
"tags": {
"add": "Añadir",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"use_page_font": "Utiliser la police de la page",
"use_page_font_description": "Utiliser la police de l'application hôte ou du site web pour ce sondage.",
"validate_id_duplicate": "L'ID {type} existe déjà dans les questions, champs masqués ou variables.",
"validate_id_empty": "Veuillez saisir un ID {type}.",
"validate_id_invalid_chars": "L'ID {type} n'est pas autorisé. Veuillez utiliser uniquement des caractères alphanumériques, des traits d'union ou des underscores.",
@@ -2340,7 +2342,9 @@
"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."
"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.",
"use_page_font_for_new_surveys": "Utiliser la police de la page",
"use_page_font_for_new_surveys_description": "Appliquer la police de l'application hôte ou du site web à tous les sondages de cet espace de travail."
},
"tags": {
"add": "Ajouter",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Felső címke",
"url_filters": "URL szűrők",
"url_not_supported": "Az URL nem támogatott",
"use_page_font": "Oldal betűtípusának használata",
"use_page_font_description": "A gazda alkalmazás vagy webhely betűtípusának használata ehhez a felméréshez.",
"validate_id_duplicate": "A(z) {type} azonosító már létezik a kérdések, rejtett mezők vagy változók között.",
"validate_id_empty": "Kérjük, adjon meg egy {type} azonosítót.",
"validate_id_invalid_chars": "A(z) {type} azonosító nem engedélyezett. Kérjük, csak alfanumerikus karaktereket, kötőjeleket vagy aláhúzásjeleket használjon.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Színek ajánlása",
"suggested_colors_applied_please_save": "Az ajánlott színek sikeresen előállítva. Nyomja meg a „Mentés” gombot a változtatások menté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."
"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.",
"use_page_font_for_new_surveys": "Oldal betűtípusának használata",
"use_page_font_for_new_surveys_description": "A gazda alkalmazás vagy webhely betűtípusának alkalmazása a munkaterület felmérésein."
},
"tags": {
"add": "Hozzáadás",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"use_page_font": "ページフォントを使用",
"use_page_font_description": "このアンケートにホストアプリまたはウェブサイトのフォントを使用します。",
"validate_id_duplicate": "{type} IDは質問、非表示フィールド、または変数に既に存在します。",
"validate_id_empty": "{type} IDを入力してください。",
"validate_id_invalid_chars": "{type} IDは使用できません。英数字、ハイフン、アンダースコアのみを使用してください。",
@@ -2340,7 +2342,9 @@
"suggest_colors": "カラーを提案",
"suggested_colors_applied_please_save": "推奨カラーが正常に生成されました。変更を保存するには“保存”を押してください。",
"theme": "テーマ",
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。"
"theme_settings_description": "すべてのアンケート用のスタイルテーマを作成します。各アンケートでカスタムスタイルを有効にできます。",
"use_page_font_for_new_surveys": "ページフォントを使用",
"use_page_font_for_new_surveys_description": "このワークスペース内のアンケート全体にホストアプリまたはウェブサイトのフォントを適用します。"
},
"tags": {
"add": "追加",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"use_page_font": "Gebruik paginafont",
"use_page_font_description": "Gebruik het lettertype van de host-app of website voor deze enquête.",
"validate_id_duplicate": "{type}-ID bestaat al in vragen, verborgen velden of variabelen.",
"validate_id_empty": "Voer een {type}-ID in.",
"validate_id_invalid_chars": "{type}-ID is niet toegestaan. Gebruik alleen alfanumerieke tekens, koppeltekens of underscores.",
@@ -2340,7 +2342,9 @@
"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."
"theme_settings_description": "Maak een stijlthema voor alle enquêtes. Je kunt aangepaste styling inschakelen voor elke enquête.",
"use_page_font_for_new_surveys": "Gebruik paginafont",
"use_page_font_for_new_surveys_description": "Pas het lettertype van de host-app of website toe op alle enquêtes in deze workspace."
},
"tags": {
"add": "Toevoegen",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"use_page_font": "Usar fonte da página",
"use_page_font_description": "Use a fonte do aplicativo ou site hospedeiro para esta pesquisa.",
"validate_id_duplicate": "O ID de {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, insira um ID de {type}.",
"validate_id_invalid_chars": "O ID de {type} não é permitido. Por favor, use apenas caracteres alfanuméricos, hífens ou underscores.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Pressione “Salvar” para persistir 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."
"theme_settings_description": "Crie um tema de estilo para todas as pesquisas. Você pode ativar estilo personalizado para cada pesquisa.",
"use_page_font_for_new_surveys": "Usar fonte da página",
"use_page_font_for_new_surveys_description": "Aplicar a fonte do aplicativo ou site hospedeiro em todas as pesquisas deste espaço de trabalho."
},
"tags": {
"add": "Adicionar",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"use_page_font": "Usar tipo de letra da página",
"use_page_font_description": "Utilizar o tipo de letra da aplicação ou website anfitrião para este inquérito.",
"validate_id_duplicate": "O ID {type} já existe em perguntas, campos ocultos ou variáveis.",
"validate_id_empty": "Por favor, introduza um ID {type}.",
"validate_id_invalid_chars": "O ID {type} não é permitido. Por favor, utilize apenas caracteres alfanuméricos, hífenes ou underscores.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Sugerir cores",
"suggested_colors_applied_please_save": "Cores sugeridas geradas com sucesso. Prima “Guardar” para persistir 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."
"theme_settings_description": "Crie um tema de estilo para todos os inquéritos. Pode ativar estilos personalizados para cada inquérito.",
"use_page_font_for_new_surveys": "Usar tipo de letra da página",
"use_page_font_for_new_surveys_description": "Aplicar o tipo de letra da aplicação ou website anfitrião em todos os inquéritos desta área de trabalho."
},
"tags": {
"add": "Adicionar",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"use_page_font": "Folosește fontul paginii",
"use_page_font_description": "Folosește fontul aplicației sau site-ului gazdă pentru acest chestionar.",
"validate_id_duplicate": "ID-ul {type} există deja în întrebări, câmpuri ascunse sau variabile.",
"validate_id_empty": "Te rugăm să introduci un ID {type}.",
"validate_id_invalid_chars": "ID-ul {type} nu este permis. Te rugăm să folosești doar caractere alfanumerice, cratimă sau liniuță de subliniere.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Sugerează culori",
"suggested_colors_applied_please_save": "Culorile sugerate au fost generate cu succes. Apăsați „Save” pentru a salva modificările.",
"theme": "Temă",
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj."
"theme_settings_description": "Creează o temă de stil pentru toate sondajele. Poți activa stilizare personalizată pentru fiecare sondaj.",
"use_page_font_for_new_surveys": "Folosește fontul paginii",
"use_page_font_for_new_surveys_description": "Aplică fontul aplicației sau site-ului gazdă pentru chestionarele din acest spațiu de lucru."
},
"tags": {
"add": "Adaugă",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"use_page_font": "Использовать шрифт страницы",
"use_page_font_description": "Использовать шрифт приложения или веб-сайта для этого опроса.",
"validate_id_duplicate": "ID {type} уже существует в вопросах, скрытых полях или переменных.",
"validate_id_empty": "Пожалуйста, введите ID {type}.",
"validate_id_invalid_chars": "ID {type} недопустим. Пожалуйста, используйте только буквенно-цифровые символы, дефисы или подчеркивания.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Предложить цвета",
"suggested_colors_applied_please_save": "Рекомендованные цвета успешно сгенерированы. Нажмите «Сохранить», чтобы применить изменения.",
"theme": "Тема",
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса."
"theme_settings_description": "Создайте стиль для всех опросов. Вы можете включить индивидуальное оформление для каждого опроса.",
"use_page_font_for_new_surveys": "Использовать шрифт страницы",
"use_page_font_for_new_surveys_description": "Применять шрифт приложения или веб-сайта ко всем опросам в этом рабочем пространстве."
},
"tags": {
"add": "Добавить",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"use_page_font": "Använd sidans typsnitt",
"use_page_font_description": "Använd värdappens eller webbplatsens typsnitt för den här enkäten.",
"validate_id_duplicate": "{type}-ID finns redan i frågor, dolda fält eller variabler.",
"validate_id_empty": "Vänligen ange ett {type}-ID.",
"validate_id_invalid_chars": "{type}-ID är inte tillåtet. Använd endast alfanumeriska tecken, bindestreck eller understreck.",
@@ -2340,7 +2342,9 @@
"suggest_colors": "Föreslå färger",
"suggested_colors_applied_please_save": "Föreslagna färger har genererats. 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."
"theme_settings_description": "Skapa ett stilmall för alla undersökningar. Du kan aktivera anpassad stil för varje undersökning.",
"use_page_font_for_new_surveys": "Använd sidans typsnitt",
"use_page_font_for_new_surveys_description": "Tillämpa värdappens eller webbplatsens typsnitt för alla enkäter i den här arbetsytan."
},
"tags": {
"add": "Lägg till",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"use_page_font": "使用页面字体",
"use_page_font_description": "为此调查问卷使用宿主应用或网站的字体。",
"validate_id_duplicate": "{type} ID 已存在于问题、隐藏字段或变量中。",
"validate_id_empty": "请输入 {type} ID。",
"validate_id_invalid_chars": "{type} ID 不允许使用。请仅使用字母数字字符、连字符或下划线。",
@@ -2340,7 +2342,9 @@
"suggest_colors": "推荐颜色",
"suggested_colors_applied_please_save": "已成功生成建议颜色。请点击“保存”以保留更改。",
"theme": "主题",
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。"
"theme_settings_description": "为所有问卷创建一个样式主题。你可以为每个问卷启用自定义样式。",
"use_page_font_for_new_surveys": "使用页面字体",
"use_page_font_for_new_surveys_description": "在此工作区的所有调查问卷中应用宿主应用或网站的字体。"
},
"tags": {
"add": "添加",
+5 -1
View File
@@ -1784,6 +1784,8 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"use_page_font": "使用頁面字型",
"use_page_font_description": "為此問卷使用主應用程式或網站字型。",
"validate_id_duplicate": "{type} ID 已存在於問題、隱藏欄位或變數中。",
"validate_id_empty": "請輸入 {type} ID。",
"validate_id_invalid_chars": "不允許使用此 {type} ID。請僅使用英數字元、連字號或底線。",
@@ -2340,7 +2342,9 @@
"suggest_colors": "建議顏色",
"suggested_colors_applied_please_save": "已成功產生建議色彩。請按「Save」以儲存變更。",
"theme": "主題",
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。"
"theme_settings_description": "為所有調查建立樣式主題。您可以為每個調查啟用自訂樣式。",
"use_page_font_for_new_surveys": "使用頁面字型",
"use_page_font_for_new_surveys_description": "在此工作區的所有問卷中套用主應用程式或網站字型。"
},
"tags": {
"add": "新增",
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
test("returns conflict error if user with email already exists", async () => {
(prisma.user.create as any).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed on the fields: (`email`)",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
}
)
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "1.0.0",
meta: { target: ["email"] },
})
);
const result = await createUser(
{ name: "Duplicate", email: "test@example.com", role: "member" },
@@ -163,7 +163,7 @@ export const ThemeStyling = ({
<div className="relative flex w-1/2 flex-col pr-6">
<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-col gap-4 rounded-lg bg-slate-50 p-4">
<div className="flex items-center gap-6">
<div className="flex flex-col gap-4">
<FormField
control={form.control}
name="allowStyleOverwrite"
@@ -229,6 +229,11 @@ export const ThemeStyling = ({
setOpen={setFormStylingOpen}
isSettingsPage
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
usePageFontFieldName="isPageFontInheritedByDefault"
usePageFontLabel={t("environments.workspace.look.use_page_font_for_new_surveys")}
usePageFontDescription={t(
"environments.workspace.look.use_page_font_for_new_surveys_description"
)}
/>
<CardStylingSettings
@@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import {
ColorField,
DimensionInput,
@@ -16,6 +17,7 @@ import {
StylingSection,
TextField,
} from "@/modules/ui/components/styling-fields";
import { Switch } from "@/modules/ui/components/switch";
type FormStylingSettingsProps = {
open: boolean;
@@ -23,6 +25,9 @@ type FormStylingSettingsProps = {
isSettingsPage?: boolean;
disabled?: boolean;
form: UseFormReturn<TProjectStyling | TSurveyStyling>;
usePageFontFieldName?: "isPageFontInherited" | "isPageFontInheritedByDefault";
usePageFontLabel?: string;
usePageFontDescription?: string;
};
export const FormStylingSettings = ({
@@ -31,6 +36,9 @@ export const FormStylingSettings = ({
disabled = false,
setOpen,
form,
usePageFontFieldName,
usePageFontLabel,
usePageFontDescription,
}: FormStylingSettingsProps) => {
const { t } = useTranslation();
@@ -86,6 +94,27 @@ export const FormStylingSettings = ({
open={headlinesOpen}
setOpen={setHeadlinesOpen}>
<div className="grid grid-cols-2 gap-4">
{usePageFontFieldName && (
<FormField
control={form.control}
name={usePageFontFieldName as never}
render={({ field }) => (
<FormItem className="col-span-2 flex w-full items-center gap-2 space-y-0 rounded-lg border border-slate-200 p-3">
<FormControl>
<Switch checked={!!field.value} onCheckedChange={(value) => field.onChange(value)} />
</FormControl>
<div>
<FormLabel>
{usePageFontLabel ?? t("environments.surveys.edit.use_page_font")}
</FormLabel>
<FormDescription>
{usePageFontDescription ?? t("environments.surveys.edit.use_page_font_description")}
</FormDescription>
</div>
</FormItem>
)}
/>
)}
<ColorField
form={form}
name="elementHeadlineColor.light"
@@ -64,25 +64,39 @@ export const StylingView = ({
const { t } = useTranslation();
const savedProjectStyling = project.styling as Partial<TProjectStyling> | null;
const { isPageFontInheritedByDefault: styleDefaultIsPageFontInheritedByDefault, ...surveyStyleDefaults } =
STYLE_DEFAULTS;
// Strip null/undefined values so they don't override STYLE_DEFAULTS.
const cleanProject = savedProjectStyling
? Object.fromEntries(Object.entries(savedProjectStyling).filter(([, v]) => v != null))
const cleanProject: Partial<TProjectStyling> = savedProjectStyling
? (Object.fromEntries(
Object.entries(savedProjectStyling).filter(([, v]) => v != null)
) as Partial<TProjectStyling>)
: {};
const cleanSurvey = localSurvey.styling
? Object.fromEntries(Object.entries(localSurvey.styling).filter(([, v]) => v != null))
const {
isPageFontInheritedByDefault: projectIsPageFontInheritedByDefault,
...cleanProjectWithoutFontDefault
} = cleanProject;
const cleanSurvey: Partial<TSurveyStyling> = localSurvey.styling
? (Object.fromEntries(
Object.entries(localSurvey.styling).filter(([, v]) => v != null)
) as Partial<TSurveyStyling>)
: {};
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProject);
const projectLegacyFills = deriveNewFieldsFromLegacy(cleanProjectWithoutFontDefault);
const surveyLegacyFills = deriveNewFieldsFromLegacy(cleanSurvey);
const form = useForm<TSurveyStyling>({
defaultValues: {
...STYLE_DEFAULTS,
...surveyStyleDefaults,
...projectLegacyFills,
...cleanProject,
...cleanProjectWithoutFontDefault,
...surveyLegacyFills,
...cleanSurvey,
isPageFontInherited:
cleanSurvey.isPageFontInherited ??
projectIsPageFontInheritedByDefault ??
styleDefaultIsPageFontInheritedByDefault,
},
});
@@ -110,16 +124,19 @@ export const StylingView = ({
};
const onResetThemeStyling = () => {
const { allowStyleOverwrite, ...baseStyling } = project.styling ?? {};
const { allowStyleOverwrite, isPageFontInheritedByDefault, ...baseStyling } = project.styling ?? {};
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...baseStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
form.reset({
...baseStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
setConfirmResetStylingModalOpen(false);
@@ -151,7 +168,7 @@ export const StylingView = ({
const defaultProjectStyling = useMemo(() => {
const { styling: projectStyling } = project;
const { allowStyleOverwrite, ...baseStyling } = projectStyling ?? {};
const { allowStyleOverwrite, isPageFontInheritedByDefault, ...baseStyling } = projectStyling ?? {};
return baseStyling;
}, [project]);
@@ -166,9 +183,11 @@ export const StylingView = ({
if (value) {
if (!styling) {
// copy the project styling to the survey styling
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
return;
}
@@ -179,9 +198,11 @@ export const StylingView = ({
}
// if there are no local styling changes, we set the styling to the project styling
else {
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: true,
isPageFontInherited,
});
}
}
@@ -192,9 +213,11 @@ export const StylingView = ({
setLocalStylingChanges(styling);
// copy the project styling to the survey styling
const isPageFontInherited = form.getValues("isPageFontInherited") ?? false;
setStyling({
...defaultProjectStyling,
overwriteThemeStyling: false,
isPageFontInherited,
});
}
};
@@ -205,30 +228,36 @@ export const StylingView = ({
<div className="mt-12 space-y-3 p-5">
{!isCxMode && (
<div className="flex items-center gap-4 py-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
id="overwrite-theme-styling"
checked={!!field.value}
onCheckedChange={handleOverwriteToggle}
/>
</FormControl>
<div className="flex w-full flex-col gap-4">
<FormField
control={form.control}
name="overwriteThemeStyling"
render={({ field }) => (
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl>
<Switch
id="overwrite-theme-styling"
checked={!!field.value}
onCheckedChange={handleOverwriteToggle}
/>
</FormControl>
<div>
<FormLabel htmlFor="overwrite-theme-styling" className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.add_custom_styles")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t("environments.surveys.edit.override_theme_with_individual_styles_for_this_survey")}
</FormDescription>
</div>
</FormItem>
)}
/>
<div>
<FormLabel
htmlFor="overwrite-theme-styling"
className="text-base font-semibold text-slate-900">
{t("environments.surveys.edit.add_custom_styles")}
</FormLabel>
<FormDescription className="text-sm text-slate-800">
{t(
"environments.surveys.edit.override_theme_with_individual_styles_for_this_survey"
)}
</FormDescription>
</div>
</FormItem>
)}
/>
</div>
</div>
)}
@@ -269,6 +298,9 @@ export const StylingView = ({
setOpen={setFormStylingOpen}
disabled={!overwriteThemeStyling}
form={form as UseFormReturn<TProjectStyling | TSurveyStyling>}
usePageFontFieldName="isPageFontInherited"
usePageFontLabel={t("environments.surveys.edit.use_page_font")}
usePageFontDescription={t("environments.surveys.edit.use_page_font_description")}
/>
<CardStylingSettings
@@ -189,10 +189,19 @@ function computeStyling(
projectStyling: TProjectStyling,
surveyStyling?: TSurveyStyling | null
): TProjectStyling | TSurveyStyling {
const resolvedIsPageFontInherited =
surveyStyling?.isPageFontInherited ?? projectStyling.isPageFontInheritedByDefault ?? false;
const fontOverrides = {
isPageFontInherited: resolvedIsPageFontInherited,
...(surveyStyling?.fontFamily !== undefined ? { fontFamily: surveyStyling.fontFamily } : {}),
};
if (!projectStyling.allowStyleOverwrite) {
return projectStyling;
return { ...projectStyling, ...fontOverrides };
}
return surveyStyling?.overwriteThemeStyling ? surveyStyling : projectStyling;
return surveyStyling?.overwriteThemeStyling
? { ...surveyStyling, isPageFontInherited: resolvedIsPageFontInherited }
: { ...projectStyling, ...fontOverrides };
}
/**
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { TProjectStyling } from "@formbricks/types/project";
import { TSurvey, TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
import { getStyling as getEffectiveStyling } from "@/lib/utils/styling";
import { ClientLogo } from "@/modules/ui/components/client-logo";
import { MediaBackground } from "@/modules/ui/components/media-background";
import { ResetProgressButton } from "@/modules/ui/components/reset-progress-button";
@@ -59,23 +60,7 @@ export const PreviewSurvey = ({
const clickOutsideClose = surveyClickOutsideClose ?? project.clickOutsideClose;
const styling: TSurveyStyling | TProjectStyling = useMemo(() => {
// allow style overwrite is disabled from the project
if (!project.styling.allowStyleOverwrite) {
return project.styling;
}
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
}
// survey style overwrite is enabled
return survey.styling;
}
return project.styling;
return getEffectiveStyling(project, survey);
}, [project.styling, survey.styling]);
const updateElementId = useCallback(
@@ -329,6 +329,19 @@ test.describe("Survey Styling", async () => {
// Navigate to Styling tab
await page.getByRole("button", { name: "Styling" }).click();
await openAccordion(page, "Survey styling");
await openAccordion(page, "Headlines & Descriptions");
const usePageFontToggle = page.getByLabel("Use page font");
await expect(usePageFontToggle).toBeVisible();
await expect(usePageFontToggle).toBeChecked();
let fontCss = await page.evaluate(() => document.getElementById("formbricks__css__custom")?.innerHTML);
expect(fontCss).not.toContain("--fb-font-family");
await usePageFontToggle.click();
await page.waitForTimeout(800);
fontCss = await page.evaluate(() => document.getElementById("formbricks__css__custom")?.innerHTML);
expect(fontCss).toContain("--fb-font-family: Inter, Helvetica, Arial, sans-serif");
// Toggle "Enable custom styling" (Survey override)
// Note: The label text might be "Add custom styles" in survey editor?
// Checking previous file: `page.getByLabel("Add custom styles")`
@@ -0,0 +1,401 @@
---
title: "Background Job Processing"
description: "How BullMQ works in Formbricks today, including the migrated response pipeline workload."
icon: "code"
---
This page documents the current BullMQ-based background job system in Formbricks and the first real workload that now runs on it: the response pipeline.
## Current State
Formbricks now uses BullMQ as an in-process background job system inside the Next.js web application.
The current implementation includes:
- a shared `@formbricks/jobs` package that owns queue creation, schemas, scheduling, and worker runtime concerns
- a Next.js startup hook that starts one BullMQ worker runtime per Node.js process without blocking app boot
- app-level enqueue helpers for request handlers
- an app-owned BullMQ response pipeline processor that replaces the legacy internal HTTP pipeline route
The first migrated workload is:
- `response-pipeline.process`
This means response-related side effects no longer depend on an internal `fetch()` back into the same app process.
## Why This Exists
The original response pipeline lived behind an internal Next.js route:
```text
apps/web/app/api/(internal)/pipeline
```
That model had a few problems:
- it was tightly coupled to the request lifecycle
- it relied on an internal HTTP hop instead of a typed background-job boundary
- it was harder to observe, retry, and scale safely
BullMQ addresses that by moving post-response work behind a queue while keeping the first version operationally simple for self-hosted users.
## High-Level Architecture
```mermaid
graph TD
A["API route or server code"] --> B["enqueueResponsePipelineEvents()"]
B --> C["getResponseSnapshotForPipeline()"]
B --> D["BackgroundJobProducer.enqueueResponsePipeline()"]
D --> E["BullMQ queue: background-jobs"]
F["instrumentation.ts"] --> G["registerJobsWorker()"]
G --> H["startJobsRuntime()"]
H --> I["BullMQ workers"]
I --> J["response-pipeline.process override"]
J --> K["processResponsePipelineJob()"]
E --> I
E --> L["Redis / Valkey"]
I --> L
```
## Responsibilities By Layer
### App Layer
- `apps/web/app/lib/pipelines.ts`
Owns enqueueing for response pipeline events. It gates queueing, hydrates the response snapshot once, logs failures, and never throws back into request handlers.
- `apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts`
Owns app-specific execution of response-pipeline jobs.
- `apps/web/modules/response-pipeline/lib/handle-integrations.ts`
Owns Slack, Notion, Airtable, and Google Sheets integration fan-out for the pipeline.
- `apps/web/modules/response-pipeline/lib/telemetry.ts`
Owns telemetry dispatch logic used by the response-created path.
- `apps/web/instrumentation-jobs.ts`
Registers the app-owned response-pipeline handler override with the shared BullMQ runtime and schedules retry after transient startup failures.
- `apps/web/lib/jobs/config.ts`
Turns environment configuration into queueing and worker-bootstrap decisions. Queue producers depend on `REDIS_URL`; worker startup additionally depends on `BULLMQ_WORKER_ENABLED`.
### Shared Jobs Layer
- `packages/jobs/src/types.ts`
Defines typed payload schemas such as `TResponsePipelineJobData`.
- `packages/jobs/src/definitions.ts`
Defines stable job names and payload validation.
- `packages/jobs/src/queue.ts`
Owns producer-side enqueueing and scheduling.
- `packages/jobs/src/runtime.ts`
Starts workers, connects Redis, and handles graceful shutdown.
- `packages/jobs/src/processors/registry.ts`
Validates payloads and dispatches named jobs, applying app-provided handler overrides when registered.
## Response Pipeline Flow
The response pipeline now runs fully in the background worker.
### Enqueueing
When a response is created or updated, the request path calls:
```ts
enqueueResponsePipelineEvents({
environmentId,
surveyId,
responseId,
events,
});
```
That helper:
1. deduplicates requested events
2. checks whether BullMQ queueing is enabled
3. uses the just-written response snapshot when the caller already has it
4. otherwise loads the latest response snapshot once via `getResponseSnapshotForPipeline(responseId)` using an uncached read
5. enqueues one BullMQ job per event with the shared snapshot payload
6. waits for the enqueue attempt to complete, then logs enqueue failures without failing the original request
### Execution
At worker startup, `apps/web/instrumentation-jobs.ts` registers an app-owned override for:
- `response-pipeline.process`
That override delegates to `processResponsePipelineJob(...)`, which performs:
- webhook delivery for all pipeline events
- integrations for `responseFinished`
- response-finished notification emails
- follow-up delivery
- survey auto-complete updates and audit logging
- response-created billing metering
- response-created telemetry dispatch
Current retry semantics are intentionally asymmetric:
- webhook delivery failures fail early BullMQ attempts so retries can happen at the job level
- if webhook delivery is still failing on the final BullMQ attempt, the worker logs that retries are exhausted and continues with the remaining event-specific side effects
- integration, email, telemetry, metering, follow-up, and survey auto-complete failures are logged inside the processor and do not fail the whole job
## Acceptance Criteria Review
### Pipeline Execution
Satisfied.
- New response create/update flows enqueue BullMQ jobs instead of calling an internal HTTP route.
- The job payload contains `environmentId`, `surveyId`, `event`, and an authoritative response snapshot.
- The response pipeline executes inside the BullMQ worker runtime.
### Feature Parity
Mostly satisfied for the legacy response pipeline behavior that existed in the old route.
The migrated BullMQ processor preserves:
- webhook delivery
- integrations
- response-finished emails
- follow-up execution
- survey auto-complete and audit logging
- response-created billing metering
- response-created telemetry
One important behavior change still exists today:
- webhook delivery failures delay the remaining side effects until the final BullMQ attempt
That is closer to the legacy route, because the pipeline eventually continues even if webhook delivery never succeeds. It is still not exact feature parity, though, because the legacy route continued immediately while the BullMQ worker waits until retries are exhausted before it degrades webhook failure into a logged condition.
### Architecture
Satisfied.
- Enqueueing lives in the app layer through `apps/web/app/lib/pipelines.ts`.
- Execution lives in the worker path under `apps/web/modules/response-pipeline/lib`.
- `@formbricks/jobs` stays responsible for queue/runtime concerns and typed job contracts.
### Cleanup
Satisfied.
The legacy internal route has been removed:
```text
apps/web/app/api/(internal)/pipeline/route.ts
```
The runtime path no longer depends on the old internal-route folder structure, and the remaining pipeline-only test mock under that deleted folder has been removed as part of the migration cleanup.
### Reliability
Satisfied at the current ticket scope.
BullMQ jobs use shared default retry behavior:
- `attempts: 3`
- exponential backoff starting at `1000ms`
Failures are logged with structured metadata such as:
- `jobId`
- `attempt`
- `jobName`
- `queueName`
- `environmentId`
- `surveyId`
- `responseId`
Request handlers remain non-blocking:
- if Redis is unavailable
- if queueing is disabled
- if snapshot hydration fails
- if enqueueing fails
the request still completes, and the failure is logged.
Worker startup is also non-blocking:
- Next.js boot does not await BullMQ readiness
- startup failures are logged
- the web app schedules a retry instead of requiring an immediate process restart
### Worker Integration
Satisfied.
The response pipeline is processed by the same BullMQ worker runtime started from Next.js instrumentation. No standalone worker service was introduced as part of this migration.
### Developer Experience
Satisfied.
The public app-level API for request handlers is intentionally small:
- `enqueueResponsePipelineEvents(...)`
This keeps queue names, Redis concerns, and BullMQ details out of response routes.
## Comparison With The Legacy Route
### Previous Implementation
The legacy internal route accepted a full response payload directly and then executed the entire pipeline synchronously inside the route handler.
Key characteristics of that model:
- request handlers performed an internal authenticated `fetch()` back into the same app
- the route received the response payload directly instead of hydrating it from a queue-side snapshot
- webhook failures were logged and did not block the rest of the pipeline
- response-finished integrations, emails, follow-ups, and survey auto-complete ran in the same route execution
- response-created metering was fire-and-forget while telemetry was awaited
### Current BullMQ Implementation
The current branch enqueues a typed snapshot-based BullMQ job and executes the pipeline inside the in-process worker registered from Next.js instrumentation.
Key characteristics of the current model:
- request handlers enqueue directly through `enqueueResponsePipelineEvents(...)`
- handlers now pass the just-written `TResponse` snapshot when they already have it
- callers that do not already have a response snapshot use an uncached pipeline-specific lookup
- worker startup is non-blocking and retries after transient failures
- webhook failures fail early attempts so BullMQ can retry them
- on the final attempt, webhook failures are logged and the remaining side effects continue
- response-created metering is awaited before the BullMQ job completes
### Net Result
Compared to the legacy route, the current branch is:
- architecturally stronger
- safer to scale and operate
- easier to observe through structured job logging
- closer to legacy feature parity than the earlier BullMQ iterations on this branch
The main remaining semantic difference is timing:
- the legacy route continued past webhook failures immediately
- the BullMQ worker now continues only after webhook retries are exhausted
That is an intentional trade-off in the current branch, not an accident.
## Current Queue Model
The queue remains intentionally small:
- queue name: `background-jobs`
- prefix: `formbricks:jobs`
- job names:
- `system.test-log`
- `response-pipeline.process`
The response pipeline is the first production workload on this queue.
## Local Development
Local development works end to end as long as Redis is available and the worker is enabled.
Required inputs:
- `REDIS_URL`
- optionally `BULLMQ_WORKER_ENABLED`
- optionally `BULLMQ_WORKER_COUNT`
- optionally `BULLMQ_WORKER_CONCURRENCY`
Behavior:
- if `REDIS_URL` is missing, queueing is skipped
- if `BULLMQ_WORKER_ENABLED=0`, the worker is not started, but request-side enqueueing can still stay enabled in deployments that point at a separate BullMQ worker
- outside tests, the worker is enabled by default
This makes it possible to develop request flows without hard-failing when Redis is absent, while still supporting full local end-to-end verification when Redis is running.
## Operational Notes
### Logging
The current implementation logs:
- worker startup failures
- Redis connection failures
- enqueue failures
- job failures
- webhook delivery failures
- integration failures
- email delivery failures
- follow-up failures
- survey auto-complete update failures
- metering failures
- telemetry failures
### Shutdown
The worker runtime registers `SIGTERM` and `SIGINT` handlers, closes workers and queue handles, and then closes Redis connections. This keeps shutdown behavior predictable inside the web process.
## Current Limitations
The migration satisfies the ticket, but a few larger architectural limits remain by design.
### Dual-Write Boundary
Response writes happen in Postgres and background jobs are enqueued in Redis. Those are separate systems, so this remains a dual-write boundary.
This means Formbricks currently has:
- non-blocking enqueue semantics
- at-least-once background execution
- no transactional guarantee that the product write and Redis enqueue succeed together
That trade-off was accepted for this BullMQ phase.
### In-Process Workers
Workers run inside the Next.js app process.
That keeps self-hosting simple, but it also means:
- job capacity still shares resources with the web process
- heavy background work is still Node.js-local
- scaling job throughput also scales the app runtime
### Webhook-Gated Retries
Webhook delivery still happens before the rest of the `responseFinished` side effects.
That gives Formbricks job-level retries for webhook delivery, but it also means:
- `responseFinished` side effects do not run on the early retry attempts
- the remaining side effects only continue after webhook retries are exhausted
- this is closer to legacy behavior than failing forever, but it is still not immediate parity
This is the current behavior of the branch and should be evaluated explicitly if we want stricter feature parity with the legacy route.
### Logs-First Observability
The current system has strong structured logging, but it does not yet provide:
- queue dashboards
- retry tooling
- latency metrics
- product-native workflow inspection
Those are future improvements, not blockers for the current migration.
## Recommended Next Steps
Now that the response pipeline is on BullMQ, the most useful next steps are:
1. migrate additional low-risk async workloads behind the same producer/runtime boundary
2. add queue metrics and worker health visibility beyond logs
3. define explicit idempotency rules for side-effect-heavy jobs
4. decide which future workloads should remain Node-local and which should eventually move to a different runtime
## Practical Conclusion
Formbricks now has:
- a production-capable BullMQ foundation
- a real migrated workload
- a clean separation between request-time enqueueing and background execution
The response pipeline migration should be considered complete for the current ticket scope.
@@ -28,6 +28,7 @@ Fine-tune how question headlines, descriptions, and upper labels appear:
| Property | Description |
|---|---|
| **Use page font** | Applies the host app or website font across surveys in this workspace. This is a workspace-level setting and affects all surveys unless a survey explicitly overrides it in the Survey Editor. |
| **Headline Color** | Color of the question headline text |
| **Description Color** | Color of the question description text |
| **Headline Font Size** | Font size for headlines (in `px` or any CSS unit) |
@@ -38,6 +39,11 @@ Fine-tune how question headlines, descriptions, and upper labels appear:
| **Upper Label Font Size** | Font size for upper labels |
| **Upper Label Font Weight** | Numeric font weight for upper labels |
<Note>
New workspaces have <strong>Use page font</strong> enabled by default. Existing workspaces keep their current
default until you change it.
</Note>
### Inputs
Control the appearance of text inputs, textareas, and other form fields:
@@ -27,6 +27,12 @@ Overwrite the global styling theme for individual surveys to create unique style
Just hit the **Save** button to apply your changes. Your survey is now ready to impress with its unique look!
<Note>
The workspace-level <strong>Use page font</strong> setting (in <strong>Look & Feel → Survey styling → Headlines & Descriptions</strong>)
applies to all surveys in the workspace by default. In this survey-level styling view, you can enable or disable
<strong>Use page font</strong> to override that default for a specific survey.
</Note>
## Overwrite CSS Styles for App & Website Surveys
You can overwrite the default CSS styles for app and website surveys by adding the following CSS to your global CSS file (e.g., `globals.css`):
@@ -311,7 +311,11 @@ describe("utils.ts", () => {
test("returns project styling if allowStyleOverwrite=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: false,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -322,13 +326,20 @@ describe("utils.ts", () => {
const result = getStyling(project, survey);
// should get project styling
expect(result).toEqual(project.styling);
expect(result).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -339,13 +350,20 @@ describe("utils.ts", () => {
const result = getStyling(project, survey);
// should get project styling still
expect(result).toEqual(project.styling);
expect(result).toEqual({
...project.styling,
isPageFontInherited: false,
});
});
test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: false,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
@@ -355,7 +373,53 @@ describe("utils.ts", () => {
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual(survey.styling);
expect(result).toEqual({
...survey.styling,
isPageFontInherited: false,
});
});
test("keeps survey font preferences when using project theme", () => {
const project = {
id: "p1",
styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: false,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual({
...project.styling,
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
});
});
test("inherits workspace default page-font setting when survey does not specify it", () => {
const project = {
id: "p1",
styling: {
allowStyleOverwrite: true,
isPageFontInheritedByDefault: true,
brandColor: { light: "#fff" },
},
} as TEnvironmentStateProject;
const survey = {
styling: {
overwriteThemeStyling: false,
} as TSurveyStyling,
} as TEnvironmentStateSurvey;
const result = getStyling(project, survey);
expect(result).toEqual({
...project.styling,
isPageFontInherited: true,
});
});
});
+19 -3
View File
@@ -142,19 +142,35 @@ export const getStyling = (
project: TEnvironmentStateProject,
survey: TEnvironmentStateSurvey
): TProjectStyling | TSurveyStyling => {
const resolvedIsPageFontInherited =
survey.styling?.isPageFontInherited ?? project.styling.isPageFontInheritedByDefault ?? false;
const getFontOverrides = () => ({
isPageFontInherited: resolvedIsPageFontInherited,
...(survey.styling?.fontFamily !== undefined ? { fontFamily: survey.styling.fontFamily } : {}),
});
// allow style overwrite is enabled from the project
if (project.styling.allowStyleOverwrite) {
// survey style overwrite is disabled
if (!survey.styling?.overwriteThemeStyling) {
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
}
// survey style overwrite is enabled
return survey.styling;
return {
...survey.styling,
isPageFontInherited: resolvedIsPageFontInherited,
};
}
// allow style overwrite is disabled from the project
return project.styling;
return {
...project.styling,
...getFontOverrides(),
};
};
export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string | undefined => {
+4 -1
View File
@@ -91,11 +91,12 @@ export interface TConfigInput {
export interface TStylingColor {
light: string;
dark?: string | null | undefined;
dark?: string | null;
}
export interface TBaseStyling {
brandColor?: TStylingColor | null;
fontFamily?: string | null;
questionColor?: TStylingColor | null;
inputColor?: TStylingColor | null;
inputBorderColor?: TStylingColor | null;
@@ -119,10 +120,12 @@ export interface TBaseStyling {
export interface TProjectStyling extends TBaseStyling {
allowStyleOverwrite: boolean;
isPageFontInheritedByDefault?: boolean | null;
}
export interface TSurveyStyling extends TBaseStyling {
overwriteThemeStyling?: boolean | null;
isPageFontInherited?: boolean | null;
}
export interface TUpdates {
+37
View File
@@ -278,6 +278,43 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-border-radius"]).toBe("8px"); // Default roundness
});
test("uses the legacy font stack when page-font inheritance is disabled", () => {
const styling = getBaseProjectStyling({});
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBe("Inter, Helvetica, Arial, sans-serif");
expect(styleElement.innerHTML).toContain("font-family: var(--fb-font-family) !important;");
});
test("inherits host font when page-font inheritance is enabled", () => {
const styling: TSurveyStyling = {
isPageFontInherited: true,
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBeUndefined();
expect(styleElement.innerHTML).not.toContain("font-family: var(--fb-font-family) !important;");
});
test("prefers explicit fontFamily over page-font inheritance", () => {
const styling: TSurveyStyling = {
isPageFontInherited: true,
fontFamily: "Inter, Noto Sans Arabic, sans-serif",
};
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
const variables = getCssVariables(styleElement);
expect(variables["--fb-font-family"]).toBe("Inter, Noto Sans Arabic, sans-serif");
expect(styleElement.innerHTML).toContain("font-family: var(--fb-font-family) !important;");
});
test("should apply brand-text-color as black for light brandColor", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FFFF00" } }); // A light color
addCustomThemeToDom({ styling });
+15
View File
@@ -7,6 +7,8 @@ import global from "@/styles/global.css?inline";
import preflight from "@/styles/preflight.css?inline";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
const LEGACY_FONT_FAMILY_STACK = "Inter, Helvetica, Arial, sans-serif";
// Store the nonce globally for style elements
let styleNonce: string | undefined;
@@ -81,6 +83,14 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
// Start the innerHTML string with #fbjs
let cssVariables = "#fbjs {\n";
const explicitFontFamily = styling.fontFamily?.trim();
const isPageFontInherited =
(styling as TSurveyStyling).isPageFontInherited ??
(styling as TProjectStyling).isPageFontInheritedByDefault ??
false;
const resolvedFontFamily =
explicitFontFamily || (isPageFontInherited ? undefined : LEGACY_FONT_FAMILY_STACK);
// Helper function to append the variable if it's not undefined
const appendCssVariable = (variableName: string, value?: string | null) => {
if (value !== undefined && value !== null) {
@@ -299,6 +309,7 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
"progress-indicator-bg-color",
styling.progressIndicatorBgColor?.light ?? styling.brandColor?.light
);
appendCssVariable("font-family", resolvedFontFamily);
// Close the #fbjs variable block
cssVariables += "}\n";
@@ -319,6 +330,10 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
};
// --- Headlines ---
if (resolvedFontFamily) {
addRule("#fbjs", " font-family: var(--fb-font-family) !important;\n");
}
let headlineDecls = "";
if (styling.elementHeadlineFontSize !== undefined)
headlineDecls += " font-size: var(--fb-element-headline-font-size) !important;\n";
+1 -1
View File
@@ -10,7 +10,7 @@
border-color: theme("borderColor.DEFAULT", currentColor);
/* 2 */
font-family: Inter, Helvetica, Arial, sans-serif;
font-family: inherit;
font-size: 1em;
}
+1
View File
@@ -5,6 +5,7 @@ import { ZBaseStyling, ZLogo } from "./styling";
export const ZProjectStyling = ZBaseStyling.extend({
allowStyleOverwrite: z.boolean(),
isPageFontInheritedByDefault: z.boolean().nullish(),
});
export type TProjectStyling = z.infer<typeof ZProjectStyling>;
+1
View File
@@ -239,6 +239,7 @@ export type TSurveyBackgroundBgType = z.infer<typeof ZSurveyBackgroundBgType>;
export const ZSurveyStyling = ZBaseStyling.extend({
overwriteThemeStyling: z.boolean().nullish(),
isPageFontInherited: z.boolean().nullish(),
});
export type TSurveyStyling = z.infer<typeof ZSurveyStyling>;