Compare commits

...

11 Commits

Author SHA1 Message Date
Dhruwang
8acbbbe344 fix: build 2025-11-24 17:00:05 +05:30
Dhruwang
3fa4df9d3f fix: build 2025-11-24 16:45:12 +05:30
Dhruwang
7304f45b22 feat: progress component + storybook setup for surveys package 2025-11-24 16:44:46 +05:30
Dhruwang
a2897a242c storybook setup for surveys package 2025-11-24 16:29:39 +05:30
Dhruwang Jariwala
ed26427302 feat: add CSP nonce support for inline styles (#6796) (#6801) 2025-11-21 15:17:39 +00:00
Matti Nannt
554809742b fix: release pipeline boolean comparison for is_latest output (#6870) 2025-11-21 09:10:55 +00:00
Johannes
28adfb905c fix: Matrix filter (#6864)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-11-21 07:13:21 +00:00
Johannes
05c455ed62 fix: Link metadata (#6865) 2025-11-21 06:56:43 +00:00
Matti Nannt
f7687bc0ea fix: pin Prisma CLI to version 6 in Dockerfile (#6868) 2025-11-21 06:36:12 +00:00
Dhruwang Jariwala
af34391309 fix: filters not persisting in response page (#6862) 2025-11-20 15:14:44 +00:00
Dhruwang Jariwala
70978fbbdf fix: update preview when props change (#6860) 2025-11-20 13:26:55 +00:00
34 changed files with 1349 additions and 103 deletions

View File

@@ -89,7 +89,7 @@ jobs:
- check-latest-release
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
docker-build-cloud:
name: Build & push Formbricks Cloud to ECR
@@ -101,7 +101,7 @@ jobs:
with:
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
IS_PRERELEASE: ${{ github.event.release.prerelease }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}
needs:
- check-latest-release
- docker-build-community
@@ -154,4 +154,4 @@ jobs:
release_tag: ${{ github.event.release.tag_name }}
commit_sha: ${{ github.sha }}
is_prerelease: ${{ github.event.release.prerelease }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
make_latest: ${{ needs.check-latest-release.outputs.is_latest == 'true' }}

View File

@@ -124,7 +124,7 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod
RUN npm install -g prisma
RUN npm install -g prisma@6
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh

View File

@@ -96,14 +96,21 @@ export const ResponsePage = ({
}
}, [searchParams, resetState]);
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
selectedFilter?.responseStatus !== "all" ||
(selectedFilter?.filter && selectedFilter.filter.length > 0) ||
(dateRange.from && dateRange.to);
useEffect(() => {
const fetchFilteredResponses = async () => {
try {
// skip call for initial mount
if (page === null) {
if (page === null && !hasFilters) {
setPage(1);
return;
}
setPage(1);
setIsFetchingFirstPage(true);
let responses: TResponseWithQuotas[] = [];
@@ -126,15 +133,7 @@ export const ResponsePage = ({
setIsFetchingFirstPage(false);
}
};
// Only fetch if filters are applied (not on initial mount with no filters)
const hasFilters =
(selectedFilter && Object.keys(selectedFilter).length > 0) ||
(dateRange && (dateRange.from || dateRange.to));
if (hasFilters) {
fetchFilteredResponses();
}
fetchFilteredResponses();
}, [filters, responsesPerPage, selectedFilter, dateRange, surveyId]);
return (

View File

@@ -4,7 +4,7 @@ import clsx from "clsx";
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { OptionsType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
@@ -26,8 +26,8 @@ import {
import { Input } from "@/modules/ui/components/input";
type QuestionFilterComboBoxProps = {
filterOptions: string[] | undefined;
filterComboBoxOptions: string[] | undefined;
filterOptions: (string | TI18nString)[] | undefined;
filterComboBoxOptions: (string | TI18nString)[] | undefined;
filterValue: string | undefined;
filterComboBoxValue: string | string[] | undefined;
onChangeFilterValue: (o: string) => void;
@@ -74,7 +74,7 @@ export const QuestionFilterComboBox = ({
if (!isMultiple) return filterComboBoxOptions;
return filterComboBoxOptions?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return !filterComboBoxValue?.includes(optionValue);
});
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
@@ -91,14 +91,15 @@ export const QuestionFilterComboBox = ({
const filteredOptions = useMemo(
() =>
options?.filter((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
}),
[options, searchQuery, defaultLanguageCode]
);
const handleCommandItemSelect = (o: string) => {
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const handleCommandItemSelect = (o: string | TI18nString) => {
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
if (isMultiple) {
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
@@ -200,14 +201,18 @@ export const QuestionFilterComboBox = ({
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-white">
{filterOptions?.map((o, index) => (
<DropdownMenuItem
key={`${o}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(o)}>
{o}
</DropdownMenuItem>
))}
{filterOptions?.map((o, index) => {
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<DropdownMenuItem
key={`${optionValue}-${index}`}
className="cursor-pointer"
onClick={() => onChangeFilterValue(optionValue)}>
{optionValue}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -269,7 +274,8 @@ export const QuestionFilterComboBox = ({
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o) => {
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
const optionValue =
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
return (
<CommandItem
key={optionValue}

View File

@@ -4,7 +4,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
SelectedFilterValue,
TResponseStatus,
@@ -13,6 +13,7 @@ import {
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { Button } from "@/modules/ui/components/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
import {
@@ -25,9 +26,17 @@ import {
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
type:
| TSurveyQuestionTypeEnum
| "Attributes"
| "Tags"
| "Languages"
| "Quotas"
| "Hidden Fields"
| "Meta"
| OptionsType.OTHERS;
filterOptions: (string | TI18nString)[];
filterComboBoxOptions: (string | TI18nString)[];
id: string;
};
@@ -69,6 +78,12 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [filterValue, setFilterValue] = useState<SelectedFilterValue>(selectedFilter);
const getDefaultFilterValue = (option?: QuestionFilterOptions): string | undefined => {
if (!option || option.filterOptions.length === 0) return undefined;
const firstOption = option.filterOptions[0];
return typeof firstOption === "object" ? getLocalizedValue(firstOption, "default") : firstOption;
};
useEffect(() => {
// Fetch the initial data for the filter and load it into the state
const handleInitialData = async () => {
@@ -94,15 +109,18 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
}, [isOpen, setSelectedOptions, survey]);
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
const matchingFilterOption = selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
);
const defaultFilterValue = getDefaultFilterValue(matchingFilterOption);
if (filterValue.filter[index].questionType) {
// Create a new array and copy existing values from SelectedFilter
filterValue.filter[index] = {
questionType: value,
filterType: {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
filterValue: defaultFilterValue,
},
};
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
@@ -111,9 +129,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
filterValue.filter[index].questionType = value;
filterValue.filter[index].filterType = {
filterComboBoxValue: undefined,
filterValue: selectedOptions.questionFilterOptions.find(
(q) => q.type === value.type || q.type === value.questionType
)?.filterOptions[0],
filterValue: defaultFilterValue,
};
setFilterValue({ ...filterValue });
}

View File

@@ -213,8 +213,8 @@ describe("surveys", () => {
id: "q8",
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Matrix" },
rows: [{ id: "r1", label: "Row 1" }],
columns: [{ id: "c1", label: "Column 1" }],
rows: [{ id: "r1", label: { default: "Row 1" } }],
columns: [{ id: "c1", label: { default: "Column 1" } }],
} as unknown as TSurveyQuestion,
],
createdAt: new Date(),

View File

@@ -76,9 +76,9 @@ export const generateQuestionAndFilterOptions = (
questionFilterOptions: QuestionFilterOptions[];
} => {
let questionOptions: QuestionOptions[] = [];
let questionFilterOptions: any = [];
let questionFilterOptions: QuestionFilterOptions[] = [];
let questionsOptions: any = [];
let questionsOptions: QuestionOption[] = [];
survey.questions.forEach((q) => {
if (Object.keys(conditionOptions).includes(q.type)) {
@@ -121,8 +121,8 @@ export const generateQuestionAndFilterOptions = (
} else if (q.type === TSurveyQuestionTypeEnum.Matrix) {
questionFilterOptions.push({
type: q.type,
filterOptions: q.rows.flatMap((row) => Object.values(row)),
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
filterOptions: q.rows.map((row) => getLocalizedValue(row.label, "default")),
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
id: q.id,
});
} else {

View File

@@ -57,6 +57,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
surveyClosedMessage: true,
showLanguageSwitch: true,
recaptcha: true,
metadata: true,
// Related data
languages: {

View File

@@ -14,6 +14,7 @@ declare global {
renderSurveyModal: (props: SurveyContainerProps) => void;
renderSurvey: (props: SurveyContainerProps) => void;
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
setNonce: (nonce: string | undefined) => void;
};
}
}
@@ -80,7 +81,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
loadScript();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [props]);
useEffect(() => {
if (isScriptLoaded) {

View File

@@ -76,6 +76,19 @@ const registerRouteChange = async (): Promise<void> => {
await queue.add(checkPageUrl, CommandType.GeneralAction);
};
/**
* Set the CSP nonce for inline styles
* @param nonce - The CSP nonce value (without 'nonce-' prefix), or undefined to clear
*/
const setNonce = (nonce: string | undefined): void => {
// Store nonce on window for access when surveys package loads
globalThis.window.__formbricksNonce = nonce;
// Set nonce in surveys package if it's already loaded
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
globalThis.window.formbricksSurveys?.setNonce?.(nonce);
};
const formbricks = {
/** @deprecated Use setup() instead. This method will be removed in a future version */
init: (initConfig: TLegacyConfigInput) => setup(initConfig as unknown as TConfigInput),
@@ -88,6 +101,7 @@ const formbricks = {
track,
logout,
registerRouteChange,
setNonce,
};
type TFormbricks = typeof formbricks;

View File

@@ -201,19 +201,24 @@ export const removeWidgetContainer = (): void => {
document.getElementById(CONTAINER_ID)?.remove();
};
const loadFormbricksSurveysExternally = (): Promise<typeof window.formbricksSurveys> => {
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
const config = Config.getInstance();
return new Promise((resolve, reject) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- We need to check if the formbricksSurveys object exists
if (window.formbricksSurveys) {
resolve(window.formbricksSurveys);
if (globalThis.window.formbricksSurveys) {
resolve(globalThis.window.formbricksSurveys);
} else {
const script = document.createElement("script");
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
script.async = true;
script.onload = () => {
resolve(window.formbricksSurveys);
// Apply stored nonce if it was set before surveys package loaded
const storedNonce = globalThis.window.__formbricksNonce;
if (storedNonce) {
globalThis.window.formbricksSurveys.setNonce(storedNonce);
}
resolve(globalThis.window.formbricksSurveys);
};
script.onerror = (error) => {
console.error("Failed to load Formbricks Surveys library:", error);

View File

@@ -1,4 +1,7 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-react.js"],
extends: [
"@formbricks/eslint-config/legacy-react.js",
"plugin:storybook/recommended"
],
parser: "@typescript-eslint/parser",
};

View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*storybook.log
storybook-static

View File

@@ -0,0 +1,25 @@
import type { StorybookConfig } from "@storybook/preact-vite";
import { dirname } from "path";
import { fileURLToPath } from "url";
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)));
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-docs"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-vitest"),
],
framework: {
name: getAbsolutePath("@storybook/preact-vite"),
options: {},
},
};
export default config;

View File

@@ -0,0 +1,21 @@
import type { Preview } from "@storybook/preact-vite";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
a11y: {
// 'todo' - show a11y violations in the test UI only
// 'error' - fail CI on a11y violations
// 'off' - skip a11y checks entirely
test: "todo",
},
},
};
export default preview;

View File

@@ -0,0 +1,7 @@
import * as a11yAddonAnnotations from "@storybook/addon-a11y/preview";
import { setProjectAnnotations } from "@storybook/preact-vite";
import * as projectAnnotations from "./preview";
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -35,7 +35,9 @@
"clean": "rimraf .turbo node_modules dist",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"i18n:generate": "npx lingo.dev@latest i18n"
"i18n:generate": "npx lingo.dev@latest i18n",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"dependencies": {
"@calcom/embed-snippet": "1.3.3",
@@ -43,26 +45,37 @@
"i18next": "25.5.2",
"i18next-icu": "2.4.0",
"isomorphic-dompurify": "2.24.0",
"preact": "10.26.6",
"preact": "10.27.2",
"react-calendar": "5.1.0",
"react-date-picker": "11.0.0",
"react-i18next": "15.7.3"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.1.3",
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/types": "workspace:*",
"@preact/preset-vite": "2.10.1",
"@storybook/addon-a11y": "^10.0.8",
"@storybook/addon-docs": "^10.0.8",
"@storybook/addon-vitest": "^10.0.8",
"@storybook/preact-vite": "^10.0.8",
"@testing-library/preact": "3.2.4",
"@types/react": "19.1.4",
"autoprefixer": "10.4.21",
"concurrently": "9.1.2",
"eslint-plugin-storybook": "^10.0.8",
"postcss": "8.5.3",
"storybook": "^10.0.8",
"tailwindcss": "3.4.17",
"terser": "5.39.1",
"vite": "6.4.1",
"vite-plugin-dts": "4.5.3",
"vite-tsconfig-paths": "5.1.4"
"vite-tsconfig-paths": "5.1.4",
"vitest": "^4.0.13",
"playwright": "^1.56.1",
"@vitest/browser-playwright": "^4.0.13",
"@vitest/coverage-v8": "^4.0.13"
}
}

View File

@@ -1,6 +1,6 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { JSX } from "preact";
import { useCallback, useEffect, useMemo, useState } from "preact/hooks";
import { type JSXInternal } from "preact/src/jsx";
import { useTranslation } from "react-i18next";
import { type TJsFileUploadParams } from "@formbricks/types/js";
import { TAllowedFileExtension, type TUploadFileConfig, mimeTypes } from "@formbricks/types/storage";
@@ -285,14 +285,14 @@ export function FileInput({
}
};
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
const handleDragOver = (e: JSX.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
// @ts-expect-error -- TS does not recognize dataTransfer
e.dataTransfer.dropEffect = "copy";
};
const handleDrop = async (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
const handleDrop = async (e: JSX.TargetedDragEvent<HTMLLabelElement>) => {
e.preventDefault();
e.stopPropagation();
@@ -300,7 +300,7 @@ export function FileInput({
await handleFileSelection(e.dataTransfer.files);
};
const handleDeleteFile = (index: number, event: JSXInternal.TargetedMouseEvent<SVGSVGElement>) => {
const handleDeleteFile = (index: number, event: JSX.TargetedMouseEvent<SVGSVGElement>) => {
event.stopPropagation();
setSelectedFiles((prevFiles) => {
const newFiles = [...prevFiles];

View File

@@ -1,7 +1,7 @@
import DOMPurify from "isomorphic-dompurify";
import { useTranslation } from "react-i18next";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
interface HeadlineProps {
headline: string;
@@ -12,8 +12,16 @@ interface HeadlineProps {
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
const { t } = useTranslation();
const isHeadlineHtml = isValidHTML(headline);
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
// Strip inline styles BEFORE parsing to avoid CSP violations
const strippedHeadline = stripInlineStyles(headline);
const isHeadlineHtml = isValidHTML(strippedHeadline);
const safeHtml =
isHeadlineHtml && strippedHeadline
? DOMPurify.sanitize(strippedHeadline, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
})
: "";
return (
<label htmlFor={questionId} className="fb-text-heading fb-mb-[3px] fb-flex fb-flex-col">

View File

@@ -1,6 +1,6 @@
import DOMPurify from "isomorphic-dompurify";
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { isValidHTML } from "@/lib/html-utils";
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
interface SubheaderProps {
subheader?: string;
@@ -8,8 +8,16 @@ interface SubheaderProps {
}
export function Subheader({ subheader, questionId }: SubheaderProps) {
const isHtml = subheader ? isValidHTML(subheader) : false;
const safeHtml = isHtml && subheader ? DOMPurify.sanitize(subheader, { ADD_ATTR: ["target"] }) : "";
// Strip inline styles BEFORE parsing to avoid CSP violations
const strippedSubheader = subheader ? stripInlineStyles(subheader) : "";
const isHtml = strippedSubheader ? isValidHTML(strippedSubheader) : false;
const safeHtml =
isHtml && strippedSubheader
? DOMPurify.sanitize(strippedSubheader, {
ADD_ATTR: ["target"],
FORBID_ATTR: ["style"], // Additional safeguard to remove any remaining inline styles
})
: "";
if (!subheader) return null;

View File

@@ -0,0 +1,49 @@
import type { JSX } from "preact";
interface ProgressProps {
value?: number;
max?: number;
containerStyling?: JSX.CSSProperties;
indicatorStyling?: JSX.CSSProperties;
"aria-label"?: string;
}
/**
* Progress component displays an indicator showing the completion progress of a task.
* Typically displayed as a progress bar.
*
* @param value - Current progress value (0-100 by default)
* @param max - Maximum value (default: 100)
* @param containerStyling - Custom styling object for the container
* @param indicatorStyling - Custom styling object for the indicator
* @param aria-label - Accessible label for the progress bar
*/
export function Progress({
value = 0,
max = 100,
containerStyling = {},
indicatorStyling = {},
"aria-label": ariaLabel = "Progress",
}: ProgressProps) {
// Calculate percentage, ensuring it stays within 0-100 range
const percentage = Math.min(Math.max((value / max) * 100, 0), 100);
return (
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={max}
aria-valuenow={value}
aria-label={ariaLabel}
className="fb-relative fb-h-2 fb-w-full fb-overflow-hidden fb-rounded-full fb-bg-accent-bg"
style={containerStyling}>
<div
className="fb-h-full fb-w-full fb-flex-1 fb-bg-brand fb-transition-all fb-duration-500 fb-ease-in-out"
style={{
transform: `translateX(-${100 - percentage}%)`,
...indicatorStyling,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,105 @@
import type { Meta, StoryObj } from "@storybook/preact-vite";
import type { ComponentProps } from "preact";
import "../../../styles/global.css";
import { Progress } from "./index";
type ProgressProps = ComponentProps<typeof Progress>;
const meta: Meta<ProgressProps> = {
title: "v5/Progress",
component: Progress,
parameters: {
layout: "centered",
docs: {
description: {
component:
"Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
},
},
},
tags: ["autodocs"],
decorators: [
(Story: any) => (
<div id="fbjs" style={{ width: "400px", padding: "20px" }}>
<Story />
</div>
),
],
argTypes: {
value: {
control: { type: "range", min: 0, max: 100, step: 1 },
description: "Current progress value",
table: {
type: { summary: "number" },
defaultValue: { summary: "0" },
},
},
max: {
control: { type: "number" },
description: "Maximum value",
table: {
type: { summary: "number" },
defaultValue: { summary: "100" },
},
},
containerStyling: {
control: "object",
description: "Custom styling object for the container",
table: {
type: { summary: "CSSProperties" },
defaultValue: { summary: "{}" },
},
},
indicatorStyling: {
control: "object",
description: "Custom styling object for the indicator",
table: {
type: { summary: "CSSProperties" },
defaultValue: { summary: "{}" },
},
},
},
};
export default meta;
type Story = StoryObj<ProgressProps>;
/**
* Default progress bar with 50% completion
*/
export const Default: Story = {
args: {
value: 50,
},
};
/**
* Progress bar with no progress (0%)
*/
export const Empty: Story = {
args: {
value: 0,
},
};
/**
* Progress bar with full progress (100%)
*/
export const Complete: Story = {
args: {
value: 100,
},
};
/**
* Progress bar with gradient indicator
*/
export const CustomStyling: Story = {
args: {
value: 70,
indicatorStyling: {
background: "linear-gradient(90deg, #3b82f6 0%, #8b5cf6 100%)",
height: "2rem",
},
},
};

View File

@@ -96,7 +96,9 @@ export const StackedCard = ({
return (
<div
ref={(el) => (cardRefs.current[dynamicQuestionIndex] = el)}
ref={(el) => {
cardRefs.current[dynamicQuestionIndex] = el;
}}
id={`questionCard-${dynamicQuestionIndex}`}
data-testid={`questionCard-${dynamicQuestionIndex}`}
key={dynamicQuestionIndex}

View File

@@ -4,7 +4,7 @@ import { RenderSurvey } from "@/components/general/render-survey";
import { I18nProvider } from "@/components/i18n/provider";
import { FILE_PICK_EVENT } from "@/lib/constants";
import { getI18nLanguage } from "@/lib/i18n-utils";
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
export const renderSurveyInline = (props: SurveyContainerProps) => {
const inlineProps: SurveyContainerProps = {
@@ -70,15 +70,17 @@ export const renderSurveyModal = renderSurvey;
export const onFilePick = (files: { name: string; type: string; base64: string }[]) => {
const fileUploadEvent = new CustomEvent(FILE_PICK_EVENT, { detail: files });
window.dispatchEvent(fileUploadEvent);
globalThis.dispatchEvent(fileUploadEvent);
};
// Initialize the global formbricksSurveys object if it doesn't exist
if (typeof window !== "undefined") {
window.formbricksSurveys = {
if (globalThis.window !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Type definition is in @formbricks/types package
(globalThis.window as any).formbricksSurveys = {
renderSurveyInline,
renderSurveyModal,
renderSurvey,
onFilePick,
};
setNonce: setStyleNonce,
} as typeof globalThis.window.formbricksSurveys;
}

View File

@@ -1,7 +1,48 @@
import { describe, expect, test } from "vitest";
import { isValidHTML } from "./html-utils";
import { isValidHTML, stripInlineStyles } from "./html-utils";
describe("html-utils", () => {
describe("stripInlineStyles", () => {
test("should remove inline styles with double quotes", () => {
const input = '<div style="color: red;">Test</div>';
const expected = "<div>Test</div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should remove inline styles with single quotes", () => {
const input = "<div style='color: red;'>Test</div>";
const expected = "<div>Test</div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should remove multiple inline styles", () => {
const input = '<div style="color: red;"><span style="font-size: 14px;">Test</span></div>';
const expected = "<div><span>Test</span></div>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should handle complex inline styles", () => {
const input = '<p style="margin: 10px; padding: 5px; background-color: blue;">Content</p>';
const expected = "<p>Content</p>";
expect(stripInlineStyles(input)).toBe(expected);
});
test("should not affect other attributes", () => {
const input = '<div class="test" id="myDiv" style="color: red;">Test</div>';
const expected = '<div class="test" id="myDiv">Test</div>';
expect(stripInlineStyles(input)).toBe(expected);
});
test("should return unchanged string if no inline styles", () => {
const input = '<div class="test">Test</div>';
expect(stripInlineStyles(input)).toBe(input);
});
test("should handle empty string", () => {
expect(stripInlineStyles("")).toBe("");
});
});
describe("isValidHTML", () => {
test("should return false for empty string", () => {
expect(isValidHTML("")).toBe(false);
@@ -22,5 +63,9 @@ describe("html-utils", () => {
test("should return true for complex HTML", () => {
expect(isValidHTML('<div class="test"><p>Test</p></div>')).toBe(true);
});
test("should handle HTML with inline styles (they should be stripped)", () => {
expect(isValidHTML('<p style="color: red;">Test</p>')).toBe(true);
});
});
});

View File

@@ -1,9 +1,23 @@
/**
* Strip inline style attributes from HTML string to avoid CSP violations
* @param html - The HTML string to process
* @returns HTML string with all style attributes removed
* @note This is a security measure to prevent CSP violations during HTML parsing
*/
export const stripInlineStyles = (html: string): string => {
// Remove style="..." or style='...' attributes
// Use separate patterns for each quote type to avoid ReDoS vulnerability
// The pattern [^"]* and [^']* are safe as they don't cause backtracking
return html.replace(/\s+style\s*=\s*["'][^"']*["']/gi, ""); //NOSONAR
};
/**
* Lightweight HTML detection for browser environments
* Uses native DOMParser (built-in, 0 KB bundle size)
* @param str - The input string to test
* @returns true if the string contains valid HTML elements, false otherwise
* @note Returns false in non-browser environments (SSR, Node.js) where window is undefined
* @note Strips inline styles before parsing to avoid CSP violations
*/
export const isValidHTML = (str: string): boolean => {
// This should ideally never happen because the surveys package should be used in an environment where DOM is available
@@ -12,7 +26,10 @@ export const isValidHTML = (str: string): boolean => {
if (!str) return false;
try {
const doc = new DOMParser().parseFromString(str, "text/html");
// Strip inline style attributes to avoid CSP violations during parsing
const strippedStr = stripInlineStyles(str);
const doc = new DOMParser().parseFromString(strippedStr, "text/html");
const errorNode = doc.querySelector("parsererror");
if (errorNode) return false;
return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1);

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { type TProjectStyling } from "@formbricks/types/project";
import { type TSurveyStyling } from "@formbricks/types/surveys/types";
import { addCustomThemeToDom, addStylesToDom } from "./styles";
import { addCustomThemeToDom, addStylesToDom, getStyleNonce, setStyleNonce } from "./styles";
// Mock CSS module imports
vi.mock("@/styles/global.css?inline", () => ({ default: ".global {}" }));
@@ -40,11 +40,85 @@ const getBaseProjectStyling = (overrides: Partial<TProjectStyling> = {}): TProje
};
};
describe("setStyleNonce and getStyleNonce", () => {
beforeEach(() => {
// Reset the DOM and nonce before each test
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
test("should set and get the nonce value", () => {
const nonce = "test-nonce-123";
setStyleNonce(nonce);
expect(getStyleNonce()).toBe(nonce);
});
test("should allow clearing the nonce with undefined", () => {
setStyleNonce("initial-nonce");
expect(getStyleNonce()).toBe("initial-nonce");
setStyleNonce(undefined);
expect(getStyleNonce()).toBeUndefined();
});
test("should update existing formbricks__css element with nonce", () => {
// Create an existing style element
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
document.head.appendChild(existingElement);
const nonce = "test-nonce-456";
setStyleNonce(nonce);
expect(existingElement.getAttribute("nonce")).toBe(nonce);
});
test("should update existing formbricks__css__custom element with nonce", () => {
// Create an existing custom style element
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
document.head.appendChild(existingElement);
const nonce = "test-nonce-789";
setStyleNonce(nonce);
expect(existingElement.getAttribute("nonce")).toBe(nonce);
});
test("should not update nonce on existing elements when nonce is undefined", () => {
// Create existing style elements
const mainElement = document.createElement("style");
mainElement.id = "formbricks__css";
mainElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(mainElement);
const customElement = document.createElement("style");
customElement.id = "formbricks__css__custom";
customElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(customElement);
setStyleNonce(undefined);
// Elements should retain their existing nonce (or be cleared if implementation removes it)
// The current implementation doesn't remove nonce when undefined, so we check it's not changed
expect(mainElement.getAttribute("nonce")).toBe("existing-nonce");
expect(customElement.getAttribute("nonce")).toBe("existing-nonce");
});
test("should handle setting nonce when elements don't exist", () => {
const nonce = "test-nonce-no-elements";
setStyleNonce(nonce);
expect(getStyleNonce()).toBe(nonce);
// Should not throw and should store the nonce for future use
});
});
describe("addStylesToDom", () => {
beforeEach(() => {
// Reset the DOM before each test
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
afterEach(() => {
@@ -52,6 +126,7 @@ describe("addStylesToDom", () => {
if (styleElement) {
styleElement.remove();
}
setStyleNonce(undefined);
});
test("should add a style element to the head with combined CSS", () => {
@@ -78,12 +153,68 @@ describe("addStylesToDom", () => {
expect(secondStyleElement).toBe(firstStyleElement);
expect(secondStyleElement?.innerHTML).toBe(initialInnerHTML);
});
test("should apply nonce to new style element when nonce is set", () => {
const nonce = "test-nonce-styles";
setStyleNonce(nonce);
addStylesToDom();
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not apply nonce when nonce is not set", () => {
addStylesToDom();
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBeNull();
});
test("should update nonce on existing style element if nonce is set after creation", () => {
addStylesToDom(); // Create element without nonce
const styleElement = document.getElementById("formbricks__css") as HTMLStyleElement;
expect(styleElement.getAttribute("nonce")).toBeNull();
const nonce = "test-nonce-update";
setStyleNonce(nonce);
addStylesToDom(); // Call again to trigger update logic
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not overwrite existing nonce when updating via addStylesToDom", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
existingElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(existingElement);
// Don't call setStyleNonce - just verify addStylesToDom doesn't overwrite
addStylesToDom(); // Should not overwrite since nonce already exists
// The update logic in addStylesToDom only sets nonce if it doesn't exist
expect(existingElement.getAttribute("nonce")).toBe("existing-nonce");
});
test("should overwrite existing nonce when setStyleNonce is called directly", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css";
existingElement.setAttribute("nonce", "existing-nonce");
document.head.appendChild(existingElement);
const newNonce = "new-nonce";
setStyleNonce(newNonce); // setStyleNonce always updates existing elements
// setStyleNonce directly updates the nonce attribute
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
});
});
describe("addCustomThemeToDom", () => {
beforeEach(() => {
document.head.innerHTML = "";
document.body.innerHTML = "";
setStyleNonce(undefined);
});
afterEach(() => {
@@ -91,6 +222,7 @@ describe("addCustomThemeToDom", () => {
if (styleElement) {
styleElement.remove();
}
setStyleNonce(undefined);
});
const getCssVariables = (styleElement: HTMLStyleElement | null): Record<string, string> => {
@@ -271,6 +403,66 @@ describe("addCustomThemeToDom", () => {
expect(variables["--fb-survey-background-color"]).toBeUndefined();
expect(variables["--fb-input-background-color"]).toBeUndefined();
});
test("should apply nonce to new custom theme style element when nonce is set", () => {
const nonce = "test-nonce-custom";
setStyleNonce(nonce);
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not apply nonce when nonce is not set", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling });
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement).not.toBeNull();
expect(styleElement.getAttribute("nonce")).toBeNull();
});
test("should update nonce on existing custom style element if nonce is set after creation", () => {
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling }); // Create element without nonce
const styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement;
expect(styleElement.getAttribute("nonce")).toBeNull();
const nonce = "test-nonce-custom-update";
setStyleNonce(nonce);
addCustomThemeToDom({ styling }); // Call again to trigger update logic
expect(styleElement.getAttribute("nonce")).toBe(nonce);
});
test("should not overwrite existing nonce when updating custom theme via addCustomThemeToDom", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
existingElement.setAttribute("nonce", "existing-custom-nonce");
document.head.appendChild(existingElement);
// Don't call setStyleNonce - just verify addCustomThemeToDom doesn't overwrite
const styling = getBaseProjectStyling({ brandColor: { light: "#FF0000" } });
addCustomThemeToDom({ styling }); // Should not overwrite since nonce already exists
// The update logic in addCustomThemeToDom only sets nonce if it doesn't exist
expect(existingElement.getAttribute("nonce")).toBe("existing-custom-nonce");
});
test("should overwrite existing nonce when setStyleNonce is called directly on custom theme", () => {
const existingElement = document.createElement("style");
existingElement.id = "formbricks__css__custom";
existingElement.setAttribute("nonce", "existing-custom-nonce");
document.head.appendChild(existingElement);
const newNonce = "new-custom-nonce";
setStyleNonce(newNonce); // setStyleNonce directly updates the nonce attribute
// setStyleNonce directly updates the nonce attribute
expect(existingElement.getAttribute("nonce")).toBe(newNonce);
});
});
describe("getBaseProjectStyling_Helper", () => {

View File

@@ -8,24 +8,74 @@ import preflight from "@/styles/preflight.css?inline";
import editorCss from "../../../../apps/web/modules/ui/components/editor/styles-editor-frontend.css?inline";
import datePickerCustomCss from "../styles/date-picker.css?inline";
// Store the nonce globally for style elements
let styleNonce: string | undefined;
/**
* Set the CSP nonce to be applied to all style elements
* @param nonce - The CSP nonce value (without 'nonce-' prefix)
*/
export const setStyleNonce = (nonce: string | undefined): void => {
styleNonce = nonce;
// Update existing style elements if they exist
const existingStyleElement = document.getElementById("formbricks__css");
if (existingStyleElement && nonce) {
existingStyleElement.setAttribute("nonce", nonce);
}
const existingCustomStyleElement = document.getElementById("formbricks__css__custom");
if (existingCustomStyleElement && nonce) {
existingCustomStyleElement.setAttribute("nonce", nonce);
}
};
export const getStyleNonce = (): string | undefined => {
return styleNonce;
};
export const addStylesToDom = () => {
if (document.getElementById("formbricks__css") === null) {
const styleElement = document.createElement("style");
styleElement.id = "formbricks__css";
// Apply nonce if available
if (styleNonce) {
styleElement.setAttribute("nonce", styleNonce);
}
styleElement.innerHTML =
preflight + global + editorCss + datePickerCss + calendarCss + datePickerCustomCss;
document.head.appendChild(styleElement);
} else {
// If style element already exists, update its nonce if needed
const existingStyleElement = document.getElementById("formbricks__css");
if (existingStyleElement && styleNonce && !existingStyleElement.getAttribute("nonce")) {
existingStyleElement.setAttribute("nonce", styleNonce);
}
}
};
export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TSurveyStyling }): void => {
// Check if the style element already exists
let styleElement = document.getElementById("formbricks__css__custom");
let styleElement = document.getElementById("formbricks__css__custom") as HTMLStyleElement | null;
// If the style element doesn't exist, create it and append to the head
if (!styleElement) {
// If the style element exists, update nonce if needed
if (styleElement) {
// Update nonce if it wasn't set before
if (styleNonce && !styleElement.getAttribute("nonce")) {
styleElement.setAttribute("nonce", styleNonce);
}
} else {
// Create it and append to the head
styleElement = document.createElement("style");
styleElement.id = "formbricks__css__custom";
// Apply nonce if available
if (styleNonce) {
styleElement.setAttribute("nonce", styleNonce);
}
document.head.appendChild(styleElement);
}

View File

@@ -27,15 +27,15 @@ describe("getUpdatedTtc", () => {
});
describe("useTtc", () => {
let mockSetTtc: ReturnType<typeof vi.fn>;
let mockSetStartTime: ReturnType<typeof vi.fn>;
let mockSetTtc: (ttc: Record<string, number>) => void;
let mockSetStartTime: (time: number) => void;
let currentTime = 0;
let initialProps: {
questionId: TSurveyQuestionId;
ttc: TResponseTtc;
setTtc: ReturnType<typeof vi.fn>;
setTtc: (ttc: Record<string, number>) => void;
startTime: number;
setStartTime: ReturnType<typeof vi.fn>;
setStartTime: (time: number) => void;
isCurrentQuestion: boolean;
};

View File

@@ -6,6 +6,7 @@
"isolatedModules": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"moduleResolution": "bundler",
"paths": {
"@/*": ["./src/*"]
},

1
packages/surveys/vitest.shims.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@vitest/browser-playwright" />

View File

@@ -7,6 +7,8 @@ declare global {
renderSurveyModal: (props: SurveyContainerProps) => void;
renderSurvey: (props: SurveyContainerProps) => void;
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
setNonce: (nonce: string | undefined) => void;
};
__formbricksNonce?: string;
}
}

657
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1 +1,36 @@
export default ["packages/*/vite.config.{ts,mts}", "apps/**/vite.config.{ts,mts}"];
import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineWorkspace } from "vitest/config";
const dirname = typeof __dirname !== "undefined" ? __dirname : path.dirname(fileURLToPath(import.meta.url));
// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon
export default [
"packages/*/vite.config.{ts,mts}",
"apps/**/vite.config.{ts,mts}",
{
extends: "packages/surveys/vite.config.mts",
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, ".storybook"),
}),
],
test: {
name: "storybook",
browser: {
enabled: true,
headless: true,
provider: "playwright",
instances: [
{
browser: "chromium",
},
],
},
setupFiles: ["packages/surveys/.storybook/vitest.setup.ts"],
},
},
];