mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-26 00:02:56 -05:00
Compare commits
4 Commits
feat/progr
...
cursor/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7d8fc6ce6 | ||
|
|
fb019c8c4c | ||
|
|
1d0ce7e5ce | ||
|
|
8b8fcf7539 |
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 == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
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 == 'true' }}
|
||||
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
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 == 'true' }}
|
||||
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||
|
||||
@@ -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@6
|
||||
RUN npm install -g prisma
|
||||
|
||||
# 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,21 +96,14 @@ 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 && !hasFilters) {
|
||||
if (page === null) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
setIsFetchingFirstPage(true);
|
||||
let responses: TResponseWithQuotas[] = [];
|
||||
|
||||
@@ -133,7 +126,15 @@ export const ResponsePage = ({
|
||||
setIsFetchingFirstPage(false);
|
||||
}
|
||||
};
|
||||
fetchFilteredResponses();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}, [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 { TI18nString, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { 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 | TI18nString)[] | undefined;
|
||||
filterComboBoxOptions: (string | TI18nString)[] | undefined;
|
||||
filterOptions: string[] | undefined;
|
||||
filterComboBoxOptions: string[] | 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" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return !filterComboBoxValue?.includes(optionValue);
|
||||
});
|
||||
}, [isMultiple, filterComboBoxOptions, filterComboBoxValue, defaultLanguageCode]);
|
||||
@@ -91,15 +91,14 @@ export const QuestionFilterComboBox = ({
|
||||
const filteredOptions = useMemo(
|
||||
() =>
|
||||
options?.filter((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
return optionValue.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
}),
|
||||
[options, searchQuery, defaultLanguageCode]
|
||||
);
|
||||
|
||||
const handleCommandItemSelect = (o: string | TI18nString) => {
|
||||
const value = typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const handleCommandItemSelect = (o: string) => {
|
||||
const value = typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
|
||||
if (isMultiple) {
|
||||
const newValue = Array.isArray(filterComboBoxValue) ? [...filterComboBoxValue, value] : [value];
|
||||
@@ -201,18 +200,14 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-white">
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{filterOptions?.map((o, index) => (
|
||||
<DropdownMenuItem
|
||||
key={`${o}-${index}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onChangeFilterValue(o)}>
|
||||
{o}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
@@ -274,8 +269,7 @@ export const QuestionFilterComboBox = ({
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions?.map((o) => {
|
||||
const optionValue =
|
||||
typeof o === "object" && o !== null ? getLocalizedValue(o, defaultLanguageCode) : o;
|
||||
const optionValue = typeof o === "object" ? 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 { TI18nString, TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
SelectedFilterValue,
|
||||
TResponseStatus,
|
||||
@@ -13,7 +13,6 @@ 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 {
|
||||
@@ -26,17 +25,9 @@ import {
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
export type QuestionFilterOptions = {
|
||||
type:
|
||||
| TSurveyQuestionTypeEnum
|
||||
| "Attributes"
|
||||
| "Tags"
|
||||
| "Languages"
|
||||
| "Quotas"
|
||||
| "Hidden Fields"
|
||||
| "Meta"
|
||||
| OptionsType.OTHERS;
|
||||
filterOptions: (string | TI18nString)[];
|
||||
filterComboBoxOptions: (string | TI18nString)[];
|
||||
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
|
||||
filterOptions: string[];
|
||||
filterComboBoxOptions: string[];
|
||||
id: string;
|
||||
};
|
||||
|
||||
@@ -78,12 +69,6 @@ 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 () => {
|
||||
@@ -109,18 +94,15 @@ 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: defaultFilterValue,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
},
|
||||
};
|
||||
setFilterValue({ filter: [...filterValue.filter], responseStatus: filterValue.responseStatus });
|
||||
@@ -129,7 +111,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
filterValue.filter[index].questionType = value;
|
||||
filterValue.filter[index].filterType = {
|
||||
filterComboBoxValue: undefined,
|
||||
filterValue: defaultFilterValue,
|
||||
filterValue: selectedOptions.questionFilterOptions.find(
|
||||
(q) => q.type === value.type || q.type === value.questionType
|
||||
)?.filterOptions[0],
|
||||
};
|
||||
setFilterValue({ ...filterValue });
|
||||
}
|
||||
|
||||
@@ -213,8 +213,8 @@ describe("surveys", () => {
|
||||
id: "q8",
|
||||
type: TSurveyQuestionTypeEnum.Matrix,
|
||||
headline: { default: "Matrix" },
|
||||
rows: [{ id: "r1", label: { default: "Row 1" } }],
|
||||
columns: [{ id: "c1", label: { default: "Column 1" } }],
|
||||
rows: [{ id: "r1", label: "Row 1" }],
|
||||
columns: [{ id: "c1", label: "Column 1" }],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -76,9 +76,9 @@ export const generateQuestionAndFilterOptions = (
|
||||
questionFilterOptions: QuestionFilterOptions[];
|
||||
} => {
|
||||
let questionOptions: QuestionOptions[] = [];
|
||||
let questionFilterOptions: QuestionFilterOptions[] = [];
|
||||
let questionFilterOptions: any = [];
|
||||
|
||||
let questionsOptions: QuestionOption[] = [];
|
||||
let questionsOptions: any = [];
|
||||
|
||||
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.map((row) => getLocalizedValue(row.label, "default")),
|
||||
filterComboBoxOptions: q.columns.map((column) => getLocalizedValue(column.label, "default")),
|
||||
filterOptions: q.rows.flatMap((row) => Object.values(row)),
|
||||
filterComboBoxOptions: q.columns.flatMap((column) => Object.values(column)),
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1667,14 +1667,34 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "An error occured while deleting survey",
|
||||
"export_survey": "Export survey",
|
||||
"filter": {
|
||||
"complete_and_partial_responses": "Complete and partial responses",
|
||||
"complete_responses": "Complete responses",
|
||||
"partial_responses": "Partial responses"
|
||||
},
|
||||
"import_error_invalid_json": "Invalid JSON file",
|
||||
"import_error_validation": "Survey validation failed",
|
||||
"import_survey": "Import Survey",
|
||||
"import_survey_description": "Import a survey from a JSON file",
|
||||
"import_survey_error": "Failed to import survey",
|
||||
"import_survey_errors": "Errors",
|
||||
"import_survey_file_label": "Select JSON file",
|
||||
"import_survey_import": "Import Survey",
|
||||
"import_survey_name_label": "Survey Name",
|
||||
"import_survey_new_id": "New Survey ID",
|
||||
"import_survey_success": "Survey imported successfully",
|
||||
"import_survey_validate": "Validate Survey",
|
||||
"import_survey_warnings": "Warnings",
|
||||
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan. Follow-ups will be removed.",
|
||||
"import_warning_images": "Images detected in survey. You'll need to re-upload images after import.",
|
||||
"import_warning_multi_language": "Multi-language surveys require an enterprise plan. Languages will be removed.",
|
||||
"import_warning_recaptcha": "Spam protection requires an enterprise plan. reCAPTCHA will be disabled.",
|
||||
"import_warning_segments": "Segment targeting will be removed. Configure targeting after import.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
"or_drag_and_drop_json": "or drag and drop a JSON file here",
|
||||
"preview_survey_in_a_new_tab": "Preview survey in a new tab",
|
||||
"read_only_user_not_allowed_to_create_survey_warning": "As a Read-Only user you are not allowed to create surveys. Please ask a user with write access to create a survey or a manager to upgrade your role.",
|
||||
"relevance": "Relevance",
|
||||
@@ -1918,6 +1938,8 @@
|
||||
"survey_deleted_successfully": "Survey deleted successfully!",
|
||||
"survey_duplicated_successfully": "Survey duplicated successfully.",
|
||||
"survey_duplication_error": "Failed to duplicate the survey.",
|
||||
"survey_export_error": "Failed to export survey",
|
||||
"survey_exported_successfully": "Survey exported successfully",
|
||||
"templates": {
|
||||
"all_channels": "All channels",
|
||||
"all_industries": "All industries",
|
||||
|
||||
@@ -57,7 +57,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
surveyClosedMessage: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
metadata: true,
|
||||
|
||||
// Related data
|
||||
languages: {
|
||||
|
||||
@@ -2,7 +2,12 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
TSurveyCreateInput,
|
||||
ZSurvey,
|
||||
ZSurveyCreateInput,
|
||||
ZSurveyFilterCriteria,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
@@ -15,7 +20,17 @@ import {
|
||||
} from "@/lib/utils/helper";
|
||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||
import { getSurvey as getFullSurvey, getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
||||
import {
|
||||
detectImagesInSurvey,
|
||||
getImportWarnings,
|
||||
stripEnterpriseFeatures,
|
||||
} from "@/modules/survey/list/lib/import-validation";
|
||||
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
||||
import {
|
||||
copySurveyToOtherEnvironment,
|
||||
@@ -263,3 +278,200 @@ export const getSurveysAction = authenticatedActionClient
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
});
|
||||
|
||||
const ZExportSurveyAction = z.object({
|
||||
surveyId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const exportSurveyAction = authenticatedActionClient
|
||||
.schema(ZExportSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await getFullSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
const ZValidateSurveyImportAction = z.object({
|
||||
surveyData: z.record(z.any()),
|
||||
environmentId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const validateSurveyImportAction = authenticatedActionClient
|
||||
.schema(ZValidateSurveyImportAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate with Zod
|
||||
const validationResult = ZSurveyCreateInput.safeParse(parsedInput.surveyData);
|
||||
if (!validationResult.success) {
|
||||
errors.push("import_error_validation");
|
||||
}
|
||||
|
||||
// Check for images
|
||||
const hasImages = detectImagesInSurvey(parsedInput.surveyData);
|
||||
|
||||
// Check permissions
|
||||
let permissions = {
|
||||
hasMultiLanguage: true,
|
||||
hasFollowUps: true,
|
||||
hasRecaptcha: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasMultiLanguage = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const organizationBillingData = await getOrganizationBilling(organizationId);
|
||||
if (organizationBillingData) {
|
||||
const isFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBillingData.plan);
|
||||
if (!isFollowUpsEnabled) {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasRecaptcha = false;
|
||||
}
|
||||
|
||||
// Get warnings
|
||||
const importWarnings = getImportWarnings(parsedInput.surveyData, hasImages, permissions);
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings: importWarnings,
|
||||
surveyName: parsedInput.surveyData?.name || "Imported Survey",
|
||||
hasImages,
|
||||
willStripFeatures: {
|
||||
multiLanguage:
|
||||
!permissions.hasMultiLanguage && (parsedInput.surveyData?.languages?.length > 1 || false),
|
||||
followUps: !permissions.hasFollowUps && (parsedInput.surveyData?.followUps?.length > 0 || false),
|
||||
recaptcha: !permissions.hasRecaptcha && (parsedInput.surveyData?.recaptcha?.enabled || false),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ZImportSurveyAction = z.object({
|
||||
surveyData: z.record(z.any()),
|
||||
environmentId: z.string().cuid2(),
|
||||
newName: z.string(),
|
||||
});
|
||||
|
||||
export const importSurveyAction = authenticatedActionClient
|
||||
.schema(ZImportSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Re-validate
|
||||
const validationResult = ZSurveyCreateInput.safeParse(parsedInput.surveyData);
|
||||
if (!validationResult.success) {
|
||||
throw new Error("Survey validation failed");
|
||||
}
|
||||
|
||||
// Check permissions and strip features
|
||||
let permissions = {
|
||||
hasMultiLanguage: true,
|
||||
hasFollowUps: true,
|
||||
hasRecaptcha: true,
|
||||
};
|
||||
|
||||
try {
|
||||
await checkMultiLanguagePermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasMultiLanguage = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const organizationBillingData = await getOrganizationBilling(organizationId);
|
||||
if (organizationBillingData) {
|
||||
const isFollowUpsEnabled = await getSurveyFollowUpsPermission(organizationBillingData.plan);
|
||||
if (!isFollowUpsEnabled) {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
permissions.hasFollowUps = false;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkSpamProtectionPermission(organizationId);
|
||||
} catch {
|
||||
permissions.hasRecaptcha = false;
|
||||
}
|
||||
|
||||
// Prepare survey for import - strip fields that should be auto-generated
|
||||
const { id, createdAt, updatedAt, ...surveyWithoutMetadata } = stripEnterpriseFeatures(
|
||||
validationResult.data,
|
||||
permissions
|
||||
);
|
||||
|
||||
const importedSurvey: TSurveyCreateInput = {
|
||||
...surveyWithoutMetadata,
|
||||
name: parsedInput.newName,
|
||||
segment: null,
|
||||
environmentId: parsedInput.environmentId,
|
||||
createdBy: ctx.user.id,
|
||||
};
|
||||
|
||||
// Create the survey
|
||||
const result = await createSurvey(parsedInput.environmentId, importedSurvey);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
264
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
264
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { importSurveyAction, validateSurveyImportAction } from "@/modules/survey/list/actions";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface ImportSurveyModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
onSurveyImported?: () => void;
|
||||
}
|
||||
|
||||
export const ImportSurveyModal = ({
|
||||
environmentId,
|
||||
open,
|
||||
setOpen,
|
||||
onSurveyImported,
|
||||
}: ImportSurveyModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [step, setStep] = useState<"upload" | "preview">("upload");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [surveyData, setSurveyData] = useState<any>(null);
|
||||
const [surveyName, setSurveyName] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSurveyId, setNewSurveyId] = useState("");
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
const [warnings, setWarnings] = useState<string[]>([]);
|
||||
|
||||
const resetState = () => {
|
||||
setStep("upload");
|
||||
setSurveyData(null);
|
||||
setSurveyName("");
|
||||
setNewName("");
|
||||
setNewSurveyId("");
|
||||
setErrors([]);
|
||||
setWarnings([]);
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
resetState();
|
||||
};
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Check file type
|
||||
if (file.type !== "application/json" && !file.name.endsWith(".json")) {
|
||||
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text);
|
||||
setSurveyData(parsed);
|
||||
setSurveyName(parsed?.name || "Imported Survey");
|
||||
setNewName(`${parsed?.name || "Imported Survey"} (imported)`);
|
||||
|
||||
// Validate the survey data
|
||||
const validationResult = await validateSurveyImportAction({
|
||||
surveyData: parsed,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (validationResult?.data) {
|
||||
setErrors(validationResult.data.errors || []);
|
||||
setWarnings(validationResult.data.warnings || []);
|
||||
|
||||
if (validationResult.data.errors.length === 0) {
|
||||
// Generate a preview ID
|
||||
const previewId = createId();
|
||||
setNewSurveyId(previewId);
|
||||
setStep("preview");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!surveyData || errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await importSurveyAction({
|
||||
surveyData,
|
||||
environmentId,
|
||||
newName,
|
||||
});
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(t("environments.surveys.import_survey_success"));
|
||||
onSurveyImported?.();
|
||||
router.refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage || t("environments.surveys.import_survey_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.import_survey_error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (step === "upload") {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="json-file">{t("environments.surveys.import_survey_file_label")}</Label>
|
||||
<div className="mt-2 flex items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-white p-8">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="mx-auto mb-4 h-12 w-12 text-slate-400" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
id="json-file"
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={loading}
|
||||
loading={loading}>
|
||||
{t("common.upload")}
|
||||
</Button>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.or_drag_and_drop_json")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={handleClose} disabled={loading}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.surveys.import_survey_validate")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
{errors.length > 0 && (
|
||||
<Alert variant="destructive" title={t("environments.surveys.import_survey_errors")}>
|
||||
<ul className="space-y-1">
|
||||
{errors.map((error) => (
|
||||
<li key={error} className="text-sm">
|
||||
• {t(`environments.surveys.${error}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{warnings.length > 0 && (
|
||||
<Alert variant="default" title={t("environments.surveys.import_survey_warnings")}>
|
||||
<ul className="space-y-1">
|
||||
{warnings.map((warning) => (
|
||||
<li key={warning} className="text-sm">
|
||||
• {t(`environments.surveys.${warning}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<div>
|
||||
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
|
||||
<Input
|
||||
id="survey-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={surveyName}
|
||||
disabled={loading}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>{t("environments.surveys.import_survey_new_id")}</Label>
|
||||
<div className="mt-2">
|
||||
<IdBadge id={newSurveyId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setStep("upload")} disabled={loading}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={errors.length > 0 || loading || !newName}
|
||||
loading={loading}>
|
||||
{t("environments.surveys.import_survey_import")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ArrowUpFromLineIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
MoreVertical,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
deleteSurveyAction,
|
||||
getSurveyAction,
|
||||
} from "@/modules/survey/list/actions";
|
||||
import { exportSurveyAction } from "@/modules/survey/list/actions";
|
||||
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
|
||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
@@ -125,6 +128,29 @@ export const SurveyDropDownMenu = ({
|
||||
setIsCautionDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleExportSurvey = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setLoading(true);
|
||||
|
||||
const result = await exportSurveyAction({ surveyId: survey.id });
|
||||
|
||||
if (result?.data) {
|
||||
const jsonString = JSON.stringify(result.data, null, 2);
|
||||
downloadSurveyJson(survey.name, jsonString);
|
||||
toast.success(t("environments.surveys.survey_exported_successfully"));
|
||||
} else {
|
||||
toast.error(t("environments.surveys.survey_export_error"));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("environments.surveys.survey_export_error"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||
@@ -170,20 +196,33 @@ export const SurveyDropDownMenu = ({
|
||||
</>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={handleExportSurvey}>
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.surveys.export_survey")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon, UploadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ImportSurveyModal } from "./import-survey-modal";
|
||||
|
||||
interface SurveysHeaderActionsProps {
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
export const SurveysHeaderActions = ({ environmentId }: SurveysHeaderActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setIsImportModalOpen(true)}>
|
||||
<UploadIcon className="h-4 w-4" />
|
||||
{t("environments.surveys.import_survey")}
|
||||
</Button>
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ImportSurveyModal
|
||||
environmentId={environmentId}
|
||||
open={isImportModalOpen}
|
||||
setOpen={setIsImportModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
28
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
28
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export const downloadSurveyJson = (surveyName: string, jsonContent: string): void => {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
throw new Error("downloadSurveyJson can only be used in a browser environment");
|
||||
}
|
||||
|
||||
const trimmedName = (surveyName ?? "").trim();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let normalizedFileName = trimmedName || `survey-${today}`;
|
||||
|
||||
if (!normalizedFileName.toLowerCase().endsWith(".json")) {
|
||||
normalizedFileName = `${normalizedFileName}-export-${today}.json`;
|
||||
}
|
||||
|
||||
const file = new File([jsonContent], normalizedFileName, {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
|
||||
const link = document.createElement("a");
|
||||
let url: string | undefined;
|
||||
|
||||
url = URL.createObjectURL(file);
|
||||
link.href = url;
|
||||
link.download = normalizedFileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
108
apps/web/modules/survey/list/lib/import-validation.ts
Normal file
108
apps/web/modules/survey/list/lib/import-validation.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const detectImagesInSurvey = (survey: any): boolean => {
|
||||
if (survey?.questions) {
|
||||
for (const question of survey.questions) {
|
||||
// Check for image fields in various question types
|
||||
if (question.imageUrl || question.videoUrl || question.fileUrl) {
|
||||
return true;
|
||||
}
|
||||
// Check for images in options
|
||||
if (question.options) {
|
||||
for (const option of question.options) {
|
||||
if (option.imageUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check welcome card
|
||||
if (survey?.welcomeCard?.fileUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check endings
|
||||
if (survey?.endings) {
|
||||
for (const ending of survey.endings) {
|
||||
if (ending.imageUrl || ending.videoUrl) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const stripEnterpriseFeatures = (
|
||||
survey: any,
|
||||
permissions: {
|
||||
hasMultiLanguage: boolean;
|
||||
hasFollowUps: boolean;
|
||||
hasRecaptcha: boolean;
|
||||
}
|
||||
): any => {
|
||||
const cleanedSurvey = { ...survey };
|
||||
|
||||
// Strip multi-language if not permitted
|
||||
if (!permissions.hasMultiLanguage) {
|
||||
cleanedSurvey.languages = [
|
||||
{
|
||||
language: {
|
||||
code: "en",
|
||||
alias: "English",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
cleanedSurvey.showLanguageSwitch = false;
|
||||
}
|
||||
|
||||
// Strip follow-ups if not permitted
|
||||
if (!permissions.hasFollowUps) {
|
||||
cleanedSurvey.followUps = [];
|
||||
}
|
||||
|
||||
// Strip recaptcha if not permitted
|
||||
if (!permissions.hasRecaptcha) {
|
||||
cleanedSurvey.recaptcha = null;
|
||||
}
|
||||
|
||||
return cleanedSurvey;
|
||||
};
|
||||
|
||||
export const getImportWarnings = (
|
||||
survey: any,
|
||||
hasImages: boolean,
|
||||
permissions: {
|
||||
hasMultiLanguage: boolean;
|
||||
hasFollowUps: boolean;
|
||||
hasRecaptcha: boolean;
|
||||
}
|
||||
): string[] => {
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!permissions.hasMultiLanguage && survey?.languages?.length > 1) {
|
||||
warnings.push("import_warning_multi_language");
|
||||
}
|
||||
|
||||
if (!permissions.hasFollowUps && survey?.followUps?.length) {
|
||||
warnings.push("import_warning_follow_ups");
|
||||
}
|
||||
|
||||
if (!permissions.hasRecaptcha && survey?.recaptcha?.enabled) {
|
||||
warnings.push("import_warning_recaptcha");
|
||||
}
|
||||
|
||||
if (hasImages) {
|
||||
warnings.push("import_warning_images");
|
||||
}
|
||||
|
||||
if (survey?.segment) {
|
||||
warnings.push("import_warning_segments");
|
||||
}
|
||||
|
||||
return warnings;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
||||
import { SurveysHeaderActions } from "@/modules/survey/list/components/surveys-header-actions";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -46,16 +47,6 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
|
||||
const currentProjectChannel = project.config.channel ?? null;
|
||||
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
|
||||
const CreateSurveyButton = () => {
|
||||
return (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
||||
{t("environments.surveys.new_survey")}
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const projectWithRequiredProps = {
|
||||
...project,
|
||||
@@ -77,7 +68,10 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
||||
if (surveyCount > 0) {
|
||||
content = (
|
||||
<>
|
||||
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
|
||||
<PageHeader
|
||||
pageTitle={t("common.surveys")}
|
||||
cta={isReadOnly ? <></> : <SurveysHeaderActions environmentId={environment.id} />}
|
||||
/>
|
||||
<SurveysList
|
||||
environmentId={environment.id}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -14,7 +14,6 @@ declare global {
|
||||
renderSurveyModal: (props: SurveyContainerProps) => void;
|
||||
renderSurvey: (props: SurveyContainerProps) => void;
|
||||
onFilePick: (files: { name: string; type: string; base64: string }[]) => void;
|
||||
setNonce: (nonce: string | undefined) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -81,7 +80,7 @@ export const SurveyInline = (props: Omit<SurveyContainerProps, "containerId">) =
|
||||
|
||||
loadScript();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [props]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScriptLoaded) {
|
||||
|
||||
@@ -76,19 +76,6 @@ 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),
|
||||
@@ -101,7 +88,6 @@ const formbricks = {
|
||||
track,
|
||||
logout,
|
||||
registerRouteChange,
|
||||
setNonce,
|
||||
};
|
||||
|
||||
type TFormbricks = typeof formbricks;
|
||||
|
||||
@@ -201,24 +201,19 @@ export const removeWidgetContainer = (): void => {
|
||||
document.getElementById(CONTAINER_ID)?.remove();
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof globalThis.window.formbricksSurveys> => {
|
||||
const loadFormbricksSurveysExternally = (): Promise<typeof 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 (globalThis.window.formbricksSurveys) {
|
||||
resolve(globalThis.window.formbricksSurveys);
|
||||
if (window.formbricksSurveys) {
|
||||
resolve(window.formbricksSurveys);
|
||||
} else {
|
||||
const script = document.createElement("script");
|
||||
script.src = `${config.get().appUrl}/js/surveys.umd.cjs`;
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
// 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);
|
||||
resolve(window.formbricksSurveys);
|
||||
};
|
||||
script.onerror = (error) => {
|
||||
console.error("Failed to load Formbricks Surveys library:", error);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
"@formbricks/eslint-config/legacy-react.js",
|
||||
"plugin:storybook/recommended"
|
||||
],
|
||||
extends: ["@formbricks/eslint-config/legacy-react.js"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
};
|
||||
|
||||
3
packages/surveys/.gitignore
vendored
3
packages/surveys/.gitignore
vendored
@@ -22,6 +22,3 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
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;
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
@@ -1,7 +0,0 @@
|
||||
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]);
|
||||
@@ -35,9 +35,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"i18n:generate": "npx lingo.dev@latest i18n",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
"i18n:generate": "npx lingo.dev@latest i18n"
|
||||
},
|
||||
"dependencies": {
|
||||
"@calcom/embed-snippet": "1.3.3",
|
||||
@@ -45,37 +43,26 @@
|
||||
"i18next": "25.5.2",
|
||||
"i18next-icu": "2.4.0",
|
||||
"isomorphic-dompurify": "2.24.0",
|
||||
"preact": "10.27.2",
|
||||
"preact": "10.26.6",
|
||||
"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",
|
||||
"vitest": "^4.0.13",
|
||||
"playwright": "^1.56.1",
|
||||
"@vitest/browser-playwright": "^4.0.13",
|
||||
"@vitest/coverage-v8": "^4.0.13"
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: JSX.TargetedDragEvent<HTMLLabelElement>) => {
|
||||
const handleDragOver = (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// @ts-expect-error -- TS does not recognize dataTransfer
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDrop = async (e: JSX.TargetedDragEvent<HTMLLabelElement>) => {
|
||||
const handleDrop = async (e: JSXInternal.TargetedDragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -300,7 +300,7 @@ export function FileInput({
|
||||
await handleFileSelection(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDeleteFile = (index: number, event: JSX.TargetedMouseEvent<SVGSVGElement>) => {
|
||||
const handleDeleteFile = (index: number, event: JSXInternal.TargetedMouseEvent<SVGSVGElement>) => {
|
||||
event.stopPropagation();
|
||||
setSelectedFiles((prevFiles) => {
|
||||
const newFiles = [...prevFiles];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import DOMPurify from "isomorphic-dompurify";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { isValidHTML, stripInlineStyles } from "@/lib/html-utils";
|
||||
import { isValidHTML } from "@/lib/html-utils";
|
||||
|
||||
interface HeadlineProps {
|
||||
headline: string;
|
||||
@@ -12,16 +12,8 @@ interface HeadlineProps {
|
||||
|
||||
export function Headline({ headline, questionId, required = true, alignTextCenter = false }: HeadlineProps) {
|
||||
const { t } = useTranslation();
|
||||
// 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
|
||||
})
|
||||
: "";
|
||||
const isHeadlineHtml = isValidHTML(headline);
|
||||
const safeHtml = isHeadlineHtml && headline ? DOMPurify.sanitize(headline, { ADD_ATTR: ["target"] }) : "";
|
||||
|
||||
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, stripInlineStyles } from "@/lib/html-utils";
|
||||
import { isValidHTML } from "@/lib/html-utils";
|
||||
|
||||
interface SubheaderProps {
|
||||
subheader?: string;
|
||||
@@ -8,16 +8,8 @@ interface SubheaderProps {
|
||||
}
|
||||
|
||||
export function Subheader({ subheader, questionId }: SubheaderProps) {
|
||||
// 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
|
||||
})
|
||||
: "";
|
||||
const isHtml = subheader ? isValidHTML(subheader) : false;
|
||||
const safeHtml = isHtml && subheader ? DOMPurify.sanitize(subheader, { ADD_ATTR: ["target"] }) : "";
|
||||
|
||||
if (!subheader) return null;
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,9 +96,7 @@ 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}
|
||||
|
||||
@@ -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, setStyleNonce } from "@/lib/styles";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "@/lib/styles";
|
||||
|
||||
export const renderSurveyInline = (props: SurveyContainerProps) => {
|
||||
const inlineProps: SurveyContainerProps = {
|
||||
@@ -70,17 +70,15 @@ export const renderSurveyModal = renderSurvey;
|
||||
|
||||
export const onFilePick = (files: { name: string; type: string; base64: string }[]) => {
|
||||
const fileUploadEvent = new CustomEvent(FILE_PICK_EVENT, { detail: files });
|
||||
globalThis.dispatchEvent(fileUploadEvent);
|
||||
window.dispatchEvent(fileUploadEvent);
|
||||
};
|
||||
|
||||
// Initialize the global formbricksSurveys object if it doesn't exist
|
||||
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 = {
|
||||
if (typeof window !== "undefined") {
|
||||
window.formbricksSurveys = {
|
||||
renderSurveyInline,
|
||||
renderSurveyModal,
|
||||
renderSurvey,
|
||||
onFilePick,
|
||||
setNonce: setStyleNonce,
|
||||
} as typeof globalThis.window.formbricksSurveys;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,48 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { isValidHTML, stripInlineStyles } from "./html-utils";
|
||||
import { isValidHTML } 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);
|
||||
@@ -63,9 +22,5 @@ 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,23 +1,9 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -26,10 +12,7 @@ export const isValidHTML = (str: string): boolean => {
|
||||
if (!str) return false;
|
||||
|
||||
try {
|
||||
// Strip inline style attributes to avoid CSP violations during parsing
|
||||
const strippedStr = stripInlineStyles(str);
|
||||
|
||||
const doc = new DOMParser().parseFromString(strippedStr, "text/html");
|
||||
const doc = new DOMParser().parseFromString(str, "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, getStyleNonce, setStyleNonce } from "./styles";
|
||||
import { addCustomThemeToDom, addStylesToDom } from "./styles";
|
||||
|
||||
// Mock CSS module imports
|
||||
vi.mock("@/styles/global.css?inline", () => ({ default: ".global {}" }));
|
||||
@@ -40,85 +40,11 @@ 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(() => {
|
||||
@@ -126,7 +52,6 @@ describe("addStylesToDom", () => {
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
test("should add a style element to the head with combined CSS", () => {
|
||||
@@ -153,68 +78,12 @@ 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(() => {
|
||||
@@ -222,7 +91,6 @@ describe("addCustomThemeToDom", () => {
|
||||
if (styleElement) {
|
||||
styleElement.remove();
|
||||
}
|
||||
setStyleNonce(undefined);
|
||||
});
|
||||
|
||||
const getCssVariables = (styleElement: HTMLStyleElement | null): Record<string, string> => {
|
||||
@@ -403,66 +271,6 @@ 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,74 +8,24 @@ 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") as HTMLStyleElement | null;
|
||||
let styleElement = document.getElementById("formbricks__css__custom");
|
||||
|
||||
// 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
|
||||
// If the style element doesn't exist, create it and append to the head
|
||||
if (!styleElement) {
|
||||
styleElement = document.createElement("style");
|
||||
styleElement.id = "formbricks__css__custom";
|
||||
|
||||
// Apply nonce if available
|
||||
if (styleNonce) {
|
||||
styleElement.setAttribute("nonce", styleNonce);
|
||||
}
|
||||
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,15 @@ describe("getUpdatedTtc", () => {
|
||||
});
|
||||
|
||||
describe("useTtc", () => {
|
||||
let mockSetTtc: (ttc: Record<string, number>) => void;
|
||||
let mockSetStartTime: (time: number) => void;
|
||||
let mockSetTtc: ReturnType<typeof vi.fn>;
|
||||
let mockSetStartTime: ReturnType<typeof vi.fn>;
|
||||
let currentTime = 0;
|
||||
let initialProps: {
|
||||
questionId: TSurveyQuestionId;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: Record<string, number>) => void;
|
||||
setTtc: ReturnType<typeof vi.fn>;
|
||||
startTime: number;
|
||||
setStartTime: (time: number) => void;
|
||||
setStartTime: ReturnType<typeof vi.fn>;
|
||||
isCurrentQuestion: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
1
packages/surveys/vitest.shims.d.ts
vendored
1
packages/surveys/vitest.shims.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="@vitest/browser-playwright" />
|
||||
2
packages/types/surveys.d.ts
vendored
2
packages/types/surveys.d.ts
vendored
@@ -7,8 +7,6 @@ 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
657
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1 @@
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
export default ["packages/*/vite.config.{ts,mts}", "apps/**/vite.config.{ts,mts}"];
|
||||
|
||||
Reference in New Issue
Block a user