mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
9 Commits
cursor/imp
...
groundwork
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b05a636914 | ||
|
|
e03df83e88 | ||
|
|
ed26427302 | ||
|
|
554809742b | ||
|
|
28adfb905c | ||
|
|
05c455ed62 | ||
|
|
f7687bc0ea | ||
|
|
af34391309 | ||
|
|
70978fbbdf |
6
.github/workflows/formbricks-release.yml
vendored
6
.github/workflows/formbricks-release.yml
vendored
@@ -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' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
183
apps/web/lib/testing/README.md
Normal file
183
apps/web/lib/testing/README.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Testing Utilities
|
||||
|
||||
Centralized testing utilities to reduce boilerplate and ensure consistency across test files.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { FIXTURES, TEST_IDS } from "@/lib/testing/constants";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
// Setup standard test environment with cleanup
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("MyModule", () => {
|
||||
test("should use standard test IDs", () => {
|
||||
// Use TEST_IDS instead of magic strings
|
||||
const result = processContact(TEST_IDS.contact);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
test("should use fixtures for test data", () => {
|
||||
// Use FIXTURES instead of defining data inline
|
||||
const result = validateEmail(FIXTURES.contact.email);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Available Utilities
|
||||
|
||||
### TEST_IDS
|
||||
|
||||
Standard identifiers to eliminate magic strings in tests.
|
||||
|
||||
**Available IDs:**
|
||||
|
||||
- `contact`, `contactAlt`
|
||||
- `user`
|
||||
- `environment`
|
||||
- `survey`
|
||||
- `organization`
|
||||
- `quota`
|
||||
- `attribute`
|
||||
- `response`
|
||||
- `team`
|
||||
- `project`
|
||||
- `segment`
|
||||
- `webhook`
|
||||
- `apiKey`
|
||||
- `membership`
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
const contactId = "contact-1";
|
||||
const envId = "env-123";
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
|
||||
// Use TEST_IDS.contact and TEST_IDS.environment
|
||||
```
|
||||
|
||||
### FIXTURES
|
||||
|
||||
Common test data structures to reduce duplication.
|
||||
|
||||
**Available fixtures:**
|
||||
|
||||
- `contact` - Basic contact object
|
||||
- `survey` - Survey object
|
||||
- `attributeKey` - Single attribute key
|
||||
- `attributeKeys` - Array of attribute keys
|
||||
- `responseData` - Sample response data
|
||||
- `environment` - Environment object
|
||||
- `organization` - Organization object
|
||||
- `project` - Project object
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
const mockContact = {
|
||||
id: "contact-1",
|
||||
email: "test@example.com",
|
||||
userId: "user-1",
|
||||
};
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { FIXTURES } from "@/lib/testing/constants";
|
||||
|
||||
// Use FIXTURES.contact directly
|
||||
```
|
||||
|
||||
### setupTestEnvironment()
|
||||
|
||||
Standardized test cleanup to replace manual beforeEach/afterEach blocks.
|
||||
|
||||
**Before:**
|
||||
|
||||
```typescript
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
**After:**
|
||||
|
||||
```typescript
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Consistency:** All tests use the same IDs and cleanup patterns
|
||||
- **Maintainability:** Update IDs in one place instead of 200+ locations
|
||||
- **Readability:** Less boilerplate, more test logic
|
||||
- **Speed:** Write new tests faster with ready-to-use fixtures
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For New Tests
|
||||
|
||||
Use these utilities immediately in all new test files.
|
||||
|
||||
### For Existing Tests
|
||||
|
||||
Migrate opportunistically when editing existing tests. No forced migration required.
|
||||
|
||||
### Example Migration
|
||||
|
||||
**Before (60 lines with boilerplate):**
|
||||
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const contactId = "contact-1";
|
||||
const environmentId = "env-1";
|
||||
|
||||
describe("getContact", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("fetches contact", async () => {
|
||||
const result = await getContact(contactId);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**After (45 lines, cleaner):**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TEST_IDS } from "@/lib/testing/constants";
|
||||
import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
|
||||
setupTestEnvironment();
|
||||
|
||||
describe("getContact", () => {
|
||||
test("fetches contact", async () => {
|
||||
const result = await getContact(TEST_IDS.contact);
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
120
apps/web/lib/testing/constants.ts
Normal file
120
apps/web/lib/testing/constants.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
/**
|
||||
* Standard test IDs to eliminate magic strings across test files.
|
||||
* Use these constants instead of hardcoded IDs like "contact-1", "env-123", etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { TEST_IDS } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should fetch contact", async () => {
|
||||
* const result = await getContact(TEST_IDS.contact);
|
||||
* expect(result).toBeDefined();
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const TEST_IDS = {
|
||||
contact: "contact-123",
|
||||
contactAlt: "contact-456",
|
||||
user: "user-123",
|
||||
environment: "env-123",
|
||||
survey: "survey-123",
|
||||
organization: "org-123",
|
||||
quota: "quota-123",
|
||||
attribute: "attr-123",
|
||||
response: "response-123",
|
||||
team: "team-123",
|
||||
project: "project-123",
|
||||
segment: "segment-123",
|
||||
webhook: "webhook-123",
|
||||
apiKey: "api-key-123",
|
||||
membership: "membership-123",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common test fixtures to reduce duplicate test data definitions.
|
||||
* Extend these as needed for your specific test cases.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { FIXTURES } from "@/lib/testing/constants";
|
||||
*
|
||||
* test("should create contact", async () => {
|
||||
* vi.mocked(getContactAttributeKeys).mockResolvedValue(FIXTURES.attributeKeys);
|
||||
* const result = await createContact(FIXTURES.contact);
|
||||
* expect(result.email).toBe(FIXTURES.contact.email);
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export const FIXTURES = {
|
||||
contact: {
|
||||
id: TEST_IDS.contact,
|
||||
email: "test@example.com",
|
||||
userId: TEST_IDS.user,
|
||||
},
|
||||
|
||||
survey: {
|
||||
id: TEST_IDS.survey,
|
||||
name: "Test Survey",
|
||||
environmentId: TEST_IDS.environment,
|
||||
},
|
||||
|
||||
attributeKey: {
|
||||
id: TEST_IDS.attribute,
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default" as const,
|
||||
},
|
||||
|
||||
attributeKeys: [
|
||||
{
|
||||
id: "key-1",
|
||||
key: "email",
|
||||
name: "Email",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
key: "name",
|
||||
name: "Name",
|
||||
environmentId: TEST_IDS.environment,
|
||||
createdAt: new Date("2024-01-01"),
|
||||
updatedAt: new Date("2024-01-02"),
|
||||
isUnique: false,
|
||||
description: null,
|
||||
type: "default",
|
||||
},
|
||||
] as TContactAttributeKey[],
|
||||
|
||||
responseData: {
|
||||
q1: "Open text answer",
|
||||
q2: "Option 1",
|
||||
},
|
||||
|
||||
environment: {
|
||||
id: TEST_IDS.environment,
|
||||
name: "Test Environment",
|
||||
type: "development" as const,
|
||||
},
|
||||
|
||||
organization: {
|
||||
id: TEST_IDS.organization,
|
||||
name: "Test Organization",
|
||||
},
|
||||
|
||||
project: {
|
||||
id: TEST_IDS.project,
|
||||
name: "Test Project",
|
||||
},
|
||||
} as const;
|
||||
31
apps/web/lib/testing/setup.ts
Normal file
31
apps/web/lib/testing/setup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Standard test environment setup with consistent cleanup patterns.
|
||||
* Call this function once at the top of your test file to ensure
|
||||
* mocks are properly cleaned up between tests.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { setupTestEnvironment } from "@/lib/testing/setup";
|
||||
*
|
||||
* setupTestEnvironment();
|
||||
*
|
||||
* describe("MyModule", () => {
|
||||
* test("should work correctly", () => {
|
||||
* // Your test code here
|
||||
* });
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Note: This replaces manual beforeEach/afterEach blocks in individual test files.
|
||||
*/
|
||||
export function setupTestEnvironment() {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
}
|
||||
@@ -57,6 +57,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
surveyClosedMessage: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
|
||||
// Related data
|
||||
languages: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -184,6 +184,10 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr
|
||||
});
|
||||
};
|
||||
|
||||
// Export new testing utilities for easy access
|
||||
export { setupTestEnvironment } from "./lib/testing/setup";
|
||||
export { TEST_IDS, FIXTURES } from "./lib/testing/constants";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
"pages": [
|
||||
"xm-and-surveys/surveys/website-app-surveys/quickstart",
|
||||
"xm-and-surveys/surveys/website-app-surveys/framework-guides",
|
||||
"xm-and-surveys/surveys/website-app-surveys/google-tag-manager",
|
||||
{
|
||||
"group": "Features",
|
||||
"icon": "wrench",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,223 @@
|
||||
---
|
||||
title: "Google Tag Manager"
|
||||
description: "Deploy Formbricks surveys through GTM without modifying your website code."
|
||||
icon: "tags"
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Google Tag Manager](https://tagmanager.google.com/) installed on your website
|
||||
- Your Formbricks **Environment ID** (Settings > Configuration > Website & App Connection)
|
||||
- Your **App URL**: `https://app.formbricks.com` (or your self-hosted URL)
|
||||
|
||||
<Note>
|
||||
Use PUBLIC_URL for multi-domain setups, WEBAPP_URL for single-domain setups.
|
||||
</Note>
|
||||
|
||||
## Basic Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Custom HTML tag in GTM">
|
||||
1. Create a new tag with preferred name e.g. "Formbricks Intercept Surveys"
|
||||
2. Tag Type: Custom HTML
|
||||
3. Paste the code from Step 2. Make sure to replace `<your-environment-id>` and if you self-host, replace `<your-app-url>`
|
||||
</Step>
|
||||
|
||||
<Step title="Add initialization script">
|
||||
|
||||
```html
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "https://app.formbricks.com"; // REPLACE ONLY IF YOUR SELF-HOST
|
||||
var environmentId = "<your-environment-id>"; // REPLACE
|
||||
var t=document.createElement("script");
|
||||
t.type="text/javascript";
|
||||
t.async=!0;
|
||||
t.src=appUrl+"/js/formbricks.umd.cjs";
|
||||
t.onload=function(){
|
||||
window.formbricks && window.formbricks.setup({
|
||||
environmentId: environmentId,
|
||||
appUrl: appUrl
|
||||
});
|
||||
};
|
||||
var e=document.getElementsByTagName("script")[0];
|
||||
e.parentNode.insertBefore(t,e);
|
||||
}();
|
||||
</script>
|
||||
```
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set trigger">
|
||||
1. Trigger: **All Pages** - Page View (default) or use case specific event
|
||||
2. Save and publish
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Test">
|
||||
1. Use GTM Preview mode
|
||||
2. Verify the tag fires
|
||||
3. Add `?formbricksDebug=true` to the URL to see test logs in browser console (see [Debugging Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#debugging-formbricks-integration) for more details)
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## User Identification
|
||||
|
||||
Identify users to enable targeting and attributes. Learn more about [user identification](/xm-and-surveys/surveys/website-app-surveys/user-identification).
|
||||
|
||||
<Note>
|
||||
User identification is part of the Formbricks [Enterprise Edition](/self-hosting/advanced/license).
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Create GTM variables">
|
||||
1. Go to Variables on GTM dashboard
|
||||
2. Create new User-defined variable
|
||||
3. Name it (e.g., "User ID")
|
||||
4. Variable Type: Data Layer Variable
|
||||
5. Data Layer Variable: "userId"
|
||||
6. Save and publish
|
||||
7. Repeat for attributes you want to track e.g. "userEmail" and "userPlan" (optional)
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Create identification tag">
|
||||
New Custom HTML tag named "Formbricks - User":
|
||||
|
||||
```html
|
||||
<script>
|
||||
(function() {
|
||||
var check = setInterval(function() {
|
||||
if (window.formbricks && window.formbricks.setUserId) {
|
||||
clearInterval(check);
|
||||
var userId = {{User ID}};
|
||||
if (userId) {
|
||||
window.formbricks.setUserId(userId);
|
||||
var attrs = {};
|
||||
if ({{User Email}}) attrs.email = {{User Email}};
|
||||
if ({{User Plan}}) attrs.plan = {{User Plan}};
|
||||
if (Object.keys(attrs).length) {
|
||||
window.formbricks.setAttributes(attrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
setTimeout(function() { clearInterval(check); }, 10000);
|
||||
})();
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Set trigger and push data">
|
||||
1. Create a custom event trigger in GTM
|
||||
2. Trigger Type: Custom Event
|
||||
3. Event name: `user-login` (or your preferred event name)
|
||||
4. Attach this trigger to your "Formbricks - User" tag
|
||||
5. Save and publish
|
||||
|
||||

|
||||
|
||||
6. In your code, push data with the same event name:
|
||||
|
||||
```javascript
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
window.dataLayer.push({
|
||||
'event': 'user-login',
|
||||
'userId': 'user-123',
|
||||
'userEmail': 'user@example.com',
|
||||
'userPlan': 'premium'
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Track Custom Events
|
||||
|
||||
<Steps>
|
||||
<Step title="Create code action in Formbricks">
|
||||
Add code action via Formbricks UI
|
||||
|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Create GTM variable for Event Name">
|
||||
1. Go to Variables on GTM dashboard
|
||||
2. Create new User-defined variable
|
||||
3. Name it "Event Name"
|
||||
4. Variable Type: Data Layer Variable
|
||||
5. Data Layer Variable: "eventName"
|
||||
6. Save and publish
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create event tracking tag">
|
||||
New Custom HTML tag:
|
||||
|
||||
```html
|
||||
<script>
|
||||
if (window.formbricks && window.formbricks.track) {
|
||||
window.formbricks.track({{Event Name}});
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Create custom trigger">
|
||||
1. Create a custom event trigger in GTM
|
||||
2. Trigger Type: Custom Event
|
||||
3. Event name: `eventName` or name that matches with your event in code.
|
||||
4. Attach this trigger to your event tracking tag
|
||||
5. Save and publish
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Fire events from your site">
|
||||
|
||||
```javascript
|
||||
// Track button click
|
||||
window.dataLayer.push({
|
||||
'event': 'eventName',
|
||||
'eventName': 'code-action'
|
||||
});
|
||||
```
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Surveys not showing?**
|
||||
- Use GTM Preview mode to check tag firing
|
||||
- Add `?formbricksDebug=true` to your URL
|
||||
- Check browser console for errors
|
||||
- Wait 1 minute for the Server Cache to refresh
|
||||
|
||||
**User ID not working?**
|
||||
- Verify Data Layer push syntax
|
||||
- Check GTM variables are reading correct values
|
||||
- Ensure user tag fires after initialization
|
||||
|
||||
**Events not tracking?**
|
||||
- Confirm `window.formbricks` exists before calling track
|
||||
- Match event names exactly with Formbricks action names
|
||||
- Check timing - Formbricks must be initialized first
|
||||
|
||||
## Need Help?
|
||||
|
||||
- [GitHub Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
- [Framework Guides](/xm-and-surveys/surveys/website-app-surveys/framework-guides)
|
||||
- [Actions](/xm-and-surveys/surveys/website-app-surveys/actions)
|
||||
- [User Identification](/xm-and-surveys/surveys/website-app-surveys/user-identification)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
2
packages/types/surveys.d.ts
vendored
2
packages/types/surveys.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user