mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-22 10:08:42 -06:00
Compare commits
8 Commits
4.7.0-rc.2
...
feat/impor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902b8c92e2 | ||
|
|
17ba0f21af | ||
|
|
a384743751 | ||
|
|
dfa1c3e375 | ||
|
|
77c9302183 | ||
|
|
88da043c00 | ||
|
|
1cc3ceec55 | ||
|
|
50d15f6e07 |
@@ -1365,6 +1365,10 @@
|
|||||||
"error_saving_changes": "Error saving changes",
|
"error_saving_changes": "Error saving changes",
|
||||||
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
|
"even_after_they_submitted_a_response_e_g_feedback_box": "Allow multiple responses; continue showing even after a response (e.g., Feedback Box).",
|
||||||
"everyone": "Everyone",
|
"everyone": "Everyone",
|
||||||
|
"export_survey": "Export survey",
|
||||||
|
"export_survey_error": "Failed to export survey",
|
||||||
|
"export_survey_loading": "Exporting survey...",
|
||||||
|
"export_survey_success": "Survey exported successfully",
|
||||||
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.",
|
"external_urls_paywall_tooltip": "Please upgrade to Startup plan to customize external URLs. This helps us prevent phishing.",
|
||||||
"fallback_missing": "Fallback missing",
|
"fallback_missing": "Fallback missing",
|
||||||
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
"fieldId_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{fieldId} is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||||
@@ -1439,6 +1443,28 @@
|
|||||||
"ignore_global_waiting_time": "Ignore project-wide waiting time",
|
"ignore_global_waiting_time": "Ignore project-wide waiting time",
|
||||||
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
"ignore_global_waiting_time_description": "This survey can show whenever its conditions are met, even if another survey was shown recently.",
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
|
"import_error_invalid_json": "Invalid JSON file",
|
||||||
|
"import_error_validation": "Survey validation failed",
|
||||||
|
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
|
||||||
|
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
|
||||||
|
"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_upload": "Upload File",
|
||||||
|
"import_survey_validate": "Validating...",
|
||||||
|
"import_survey_warnings": "Warnings",
|
||||||
|
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
|
||||||
|
"import_warning_follow_ups": "Survey follow-ups require an enterprise plan and might 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 and might be removed.",
|
||||||
|
"import_warning_recaptcha": "Spam protection requires an enterprise plan and might be disabled.",
|
||||||
|
"import_warning_segments": "Segment targeting cannot be imported. Configure targeting after import.",
|
||||||
"includes_all_of": "Includes all of",
|
"includes_all_of": "Includes all of",
|
||||||
"includes_one_of": "Includes one of",
|
"includes_one_of": "Includes one of",
|
||||||
"initial_value": "Initial value",
|
"initial_value": "Initial value",
|
||||||
@@ -1688,11 +1714,37 @@
|
|||||||
"zip": "Zip"
|
"zip": "Zip"
|
||||||
},
|
},
|
||||||
"error_deleting_survey": "An error occured while deleting survey",
|
"error_deleting_survey": "An error occured while deleting survey",
|
||||||
|
"export_survey": "Export survey",
|
||||||
|
"export_survey_error": "Failed to export survey",
|
||||||
|
"export_survey_loading": "Exporting survey...",
|
||||||
|
"export_survey_success": "Survey exported successfully",
|
||||||
"filter": {
|
"filter": {
|
||||||
"complete_and_partial_responses": "Complete and partial responses",
|
"complete_and_partial_responses": "Complete and partial responses",
|
||||||
"complete_responses": "Complete responses",
|
"complete_responses": "Complete responses",
|
||||||
"partial_responses": "Partial responses"
|
"partial_responses": "Partial responses"
|
||||||
},
|
},
|
||||||
|
"import_error_invalid_json": "Invalid JSON file",
|
||||||
|
"import_error_validation": "Survey validation failed",
|
||||||
|
"import_info_quotas": "Due to the complexity of quotas, they are not being imported. Please create them manually after import.",
|
||||||
|
"import_info_triggers": "Triggers will be automatically matched or created in your environment.",
|
||||||
|
"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_upload": "Upload File",
|
||||||
|
"import_survey_validate": "Validating...",
|
||||||
|
"import_survey_warnings": "Warnings",
|
||||||
|
"import_warning_action_classes": "Action classes will be matched or created in the target environment.",
|
||||||
|
"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 cannot be imported. Configure targeting after import.",
|
||||||
"new_survey": "New Survey",
|
"new_survey": "New Survey",
|
||||||
"no_surveys_created_yet": "No surveys created yet",
|
"no_surveys_created_yet": "No surveys created yet",
|
||||||
"open_options": "Open options",
|
"open_options": "Open options",
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ export const createSurvey = async (
|
|||||||
try {
|
try {
|
||||||
const { createdBy, ...restSurveyBody } = surveyBody;
|
const { createdBy, ...restSurveyBody } = surveyBody;
|
||||||
|
|
||||||
// empty languages array
|
const hasLanguages = Array.isArray(restSurveyBody.languages)
|
||||||
if (!restSurveyBody.languages?.length) {
|
? restSurveyBody.languages.length > 0
|
||||||
|
: restSurveyBody.languages &&
|
||||||
|
typeof restSurveyBody.languages === "object" &&
|
||||||
|
"create" in restSurveyBody.languages;
|
||||||
|
|
||||||
|
if (!hasLanguages) {
|
||||||
delete restSurveyBody.languages;
|
delete restSurveyBody.languages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ export const LinkSurveyWrapper = ({
|
|||||||
styling={styling}
|
styling={styling}
|
||||||
onBackgroundLoaded={handleBackgroundLoaded}>
|
onBackgroundLoaded={handleBackgroundLoaded}>
|
||||||
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
|
<div className="flex max-h-dvh min-h-dvh items-center justify-center overflow-clip">
|
||||||
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && <ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />}
|
{!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && (
|
||||||
|
<ClientLogo projectLogo={project.logo} surveyLogo={styling.logo} />
|
||||||
|
)}
|
||||||
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
|
||||||
{isPreview && (
|
{isPreview && (
|
||||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
import { ZSurveyFilterCriteria } from "@formbricks/types/surveys/types";
|
||||||
|
import { getProject } from "@/lib/project/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
@@ -15,12 +16,31 @@ import {
|
|||||||
} from "@/lib/utils/helper";
|
} from "@/lib/utils/helper";
|
||||||
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
import { generateSurveySingleUseIds } from "@/lib/utils/single-use-surveys";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
|
import { getSurvey as getSurveyFull } from "@/modules/survey/lib/survey";
|
||||||
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
import { getProjectIdIfEnvironmentExists } from "@/modules/survey/list/lib/environment";
|
||||||
|
import { ZSurveyExportPayload, transformSurveyForExport } from "@/modules/survey/list/lib/export-survey";
|
||||||
|
import {
|
||||||
|
type TSurveyLanguageConnection,
|
||||||
|
addLanguageLabels,
|
||||||
|
mapLanguages,
|
||||||
|
mapTriggers,
|
||||||
|
normalizeLanguagesForCreation,
|
||||||
|
parseSurveyPayload,
|
||||||
|
persistSurvey,
|
||||||
|
resolveImportCapabilities,
|
||||||
|
} from "@/modules/survey/list/lib/import";
|
||||||
|
import {
|
||||||
|
buildImportWarnings,
|
||||||
|
detectImagesInSurvey,
|
||||||
|
getLanguageNames,
|
||||||
|
stripUnavailableFeatures,
|
||||||
|
} from "@/modules/survey/list/lib/import-helpers";
|
||||||
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
import { getUserProjects } from "@/modules/survey/list/lib/project";
|
||||||
import {
|
import {
|
||||||
copySurveyToOtherEnvironment,
|
copySurveyToOtherEnvironment,
|
||||||
deleteSurvey,
|
deleteSurvey,
|
||||||
getSurvey,
|
getSurvey,
|
||||||
|
getSurvey as getSurveyMinimal,
|
||||||
getSurveys,
|
getSurveys,
|
||||||
} from "@/modules/survey/list/lib/survey";
|
} from "@/modules/survey/list/lib/survey";
|
||||||
|
|
||||||
@@ -47,7 +67,35 @@ export const getSurveyAction = authenticatedActionClient
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getSurvey(parsedInput.surveyId);
|
return await getSurveyMinimal(parsedInput.surveyId);
|
||||||
|
});
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const survey = await getSurveyFull(parsedInput.surveyId);
|
||||||
|
|
||||||
|
return transformSurveyForExport(survey);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZCopySurveyToOtherEnvironmentAction = z.object({
|
const ZCopySurveyToOtherEnvironmentAction = z.object({
|
||||||
@@ -92,7 +140,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// authorization check for source environment
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: sourceEnvironmentOrganizationId,
|
organizationId: sourceEnvironmentOrganizationId,
|
||||||
@@ -109,7 +156,6 @@ export const copySurveyToOtherEnvironmentAction = authenticatedActionClient
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// authorization check for target environment
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
organizationId: targetEnvironmentOrganizationId,
|
organizationId: targetEnvironmentOrganizationId,
|
||||||
@@ -263,3 +309,168 @@ export const getSurveysAction = authenticatedActionClient
|
|||||||
parsedInput.filterCriteria
|
parsedInput.filterCriteria
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZValidateSurveyImportAction = z.object({
|
||||||
|
surveyData: ZSurveyExportPayload,
|
||||||
|
environmentId: z.string().cuid2(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const validateSurveyImportAction = authenticatedActionClient
|
||||||
|
.schema(ZValidateSurveyImportAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
// Step 1: Parse and validate payload structure
|
||||||
|
const parseResult = parseSurveyPayload(parsedInput.surveyData);
|
||||||
|
if ("error" in parseResult) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors:
|
||||||
|
parseResult.details && parseResult.details.length > 0
|
||||||
|
? [parseResult.error, ...parseResult.details]
|
||||||
|
: [parseResult.error],
|
||||||
|
warnings: [],
|
||||||
|
infos: [],
|
||||||
|
surveyName: parsedInput.surveyData.data.name || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { surveyInput, exportedLanguages, triggers } = parseResult;
|
||||||
|
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger validation is now handled by Zod schema validation
|
||||||
|
|
||||||
|
const languageCodes = exportedLanguages.map((l) => l.code).filter(Boolean);
|
||||||
|
if (languageCodes.length > 0) {
|
||||||
|
const project = await getProject(projectId);
|
||||||
|
const existingLanguageCodes = project?.languages.map((l) => l.code) || [];
|
||||||
|
|
||||||
|
const missingLanguages = languageCodes.filter((code: string) => !existingLanguageCodes.includes(code));
|
||||||
|
|
||||||
|
if (missingLanguages.length > 0) {
|
||||||
|
const languageNames = getLanguageNames(missingLanguages);
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errors: [
|
||||||
|
`Before you can continue, please setup the following languages in your Project Configuration: ${languageNames.join(", ")}`,
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
infos: [],
|
||||||
|
surveyName: surveyInput.name || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings = await buildImportWarnings(surveyInput, organizationId);
|
||||||
|
const infos: string[] = [];
|
||||||
|
|
||||||
|
const hasImages = detectImagesInSurvey(surveyInput);
|
||||||
|
if (hasImages) {
|
||||||
|
warnings.push("import_warning_images");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggers && triggers.length > 0) {
|
||||||
|
infos.push("import_info_triggers");
|
||||||
|
}
|
||||||
|
|
||||||
|
infos.push("import_info_quotas");
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings,
|
||||||
|
infos,
|
||||||
|
surveyName: surveyInput.name || "Imported Survey",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const ZImportSurveyAction = z.object({
|
||||||
|
surveyData: ZSurveyExportPayload,
|
||||||
|
environmentId: z.string().cuid2(),
|
||||||
|
newName: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importSurveyAction = authenticatedActionClient
|
||||||
|
.schema(ZImportSurveyAction)
|
||||||
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 1: Parse and validate survey payload
|
||||||
|
const parseResult = parseSurveyPayload(parsedInput.surveyData);
|
||||||
|
if ("error" in parseResult) {
|
||||||
|
const errorMessage =
|
||||||
|
parseResult.details && parseResult.details.length > 0
|
||||||
|
? `${parseResult.error}:\n${parseResult.details.join("\n")}`
|
||||||
|
: parseResult.error;
|
||||||
|
throw new Error(`Validation failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
const { surveyInput, exportedLanguages, triggers } = parseResult;
|
||||||
|
|
||||||
|
const capabilities = await resolveImportCapabilities(organizationId);
|
||||||
|
|
||||||
|
const triggerResult = await mapTriggers(triggers, parsedInput.environmentId);
|
||||||
|
|
||||||
|
const cleanedSurvey = await stripUnavailableFeatures(surveyInput, parsedInput.environmentId);
|
||||||
|
|
||||||
|
let mappedLanguages: TSurveyLanguageConnection | undefined = undefined;
|
||||||
|
let languageCodes: string[] = [];
|
||||||
|
|
||||||
|
if (exportedLanguages.length > 0 && capabilities.hasMultiLanguage) {
|
||||||
|
const projectId = await getProjectIdFromEnvironmentId(parsedInput.environmentId);
|
||||||
|
const langResult = await mapLanguages(exportedLanguages, projectId);
|
||||||
|
|
||||||
|
if (langResult.mapped.length > 0) {
|
||||||
|
mappedLanguages = normalizeLanguagesForCreation(langResult.mapped);
|
||||||
|
languageCodes = exportedLanguages.filter((l) => !l.default).map((l) => l.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const surveyWithTranslations = addLanguageLabels(cleanedSurvey, languageCodes);
|
||||||
|
|
||||||
|
const result = await persistSurvey(
|
||||||
|
parsedInput.environmentId,
|
||||||
|
surveyWithTranslations,
|
||||||
|
parsedInput.newName,
|
||||||
|
ctx.user.id,
|
||||||
|
triggerResult.mapped,
|
||||||
|
mappedLanguages
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { UploadIcon } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { ImportSurveyModal } from "./import-survey-modal";
|
||||||
|
|
||||||
|
interface ImportSurveyButtonProps {
|
||||||
|
environmentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportSurveyButton = ({ environmentId }: ImportSurveyButtonProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setOpen(true)}>
|
||||||
|
<UploadIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("environments.surveys.import_survey")}
|
||||||
|
</Button>
|
||||||
|
<ImportSurveyModal environmentId={environmentId} open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
320
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
320
apps/web/modules/survey/list/components/import-survey-modal.tsx
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowUpFromLineIcon, CheckIcon } from "lucide-react";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { importSurveyAction, validateSurveyImportAction } from "@/modules/survey/list/actions";
|
||||||
|
import { type TSurveyExportPayload } from "@/modules/survey/list/lib/export-survey";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/modules/ui/components/dialog";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
import { Label } from "@/modules/ui/components/label";
|
||||||
|
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
||||||
|
|
||||||
|
interface ImportSurveyModalProps {
|
||||||
|
environmentId: string;
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImportSurveyModal = ({ environmentId, open, setOpen }: ImportSurveyModalProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [fileName, setFileName] = useState<string>("");
|
||||||
|
const [surveyData, setSurveyData] = useState<TSurveyExportPayload | null>(null);
|
||||||
|
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||||
|
const [validationWarnings, setValidationWarnings] = useState<string[]>([]);
|
||||||
|
const [validationInfos, setValidationInfos] = useState<string[]>([]);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [isValid, setIsValid] = useState(false);
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
setFileName("");
|
||||||
|
setSurveyData(null);
|
||||||
|
setValidationErrors([]);
|
||||||
|
setValidationWarnings([]);
|
||||||
|
setValidationInfos([]);
|
||||||
|
setNewName("");
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsValid(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenChange = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
setOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processJSONFile = async (file: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.type !== "application/json" && !file.name.endsWith(".json")) {
|
||||||
|
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||||
|
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
|
||||||
|
setFileName("");
|
||||||
|
setIsValid(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileName(file.name);
|
||||||
|
setIsLoading(true);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (event) => {
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(event.target?.result as string);
|
||||||
|
setSurveyData(json);
|
||||||
|
|
||||||
|
const result = await validateSurveyImportAction({
|
||||||
|
surveyData: json,
|
||||||
|
environmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
setValidationErrors(result.data.errors || []);
|
||||||
|
setValidationWarnings(result.data.warnings || []);
|
||||||
|
setValidationInfos(result.data.infos || []);
|
||||||
|
setIsValid(result.data.valid);
|
||||||
|
|
||||||
|
if (result.data.valid) {
|
||||||
|
setNewName(result.data.surveyName + " (imported)");
|
||||||
|
}
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
setValidationErrors([result.serverError]);
|
||||||
|
setValidationWarnings([]);
|
||||||
|
setValidationInfos([]);
|
||||||
|
setIsValid(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("environments.surveys.import_error_invalid_json"));
|
||||||
|
setValidationErrors([t("environments.surveys.import_error_invalid_json")]);
|
||||||
|
setValidationWarnings([]);
|
||||||
|
setValidationInfos([]);
|
||||||
|
setIsValid(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
processJSONFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.dropEffect = "copy";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files[0];
|
||||||
|
if (file) {
|
||||||
|
processJSONFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!surveyData) {
|
||||||
|
toast.error(t("environments.surveys.import_survey_error"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await importSurveyAction({
|
||||||
|
surveyData,
|
||||||
|
environmentId,
|
||||||
|
newName,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
toast.success(t("environments.surveys.import_survey_success"));
|
||||||
|
onOpenChange(false);
|
||||||
|
window.location.href = `/environments/${environmentId}/surveys/${result.data.surveyId}/edit`;
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
console.error("[Import Survey] Server error:", result.serverError);
|
||||||
|
toast.error(result.serverError);
|
||||||
|
} else {
|
||||||
|
console.error("[Import Survey] Unknown error - no data or serverError returned");
|
||||||
|
toast.error(t("environments.surveys.import_survey_error"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Import Survey] Exception caught:", error);
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : t("environments.surveys.import_survey_error");
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderUploadSection = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor="import-file"
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg hover:bg-slate-100"
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}>
|
||||||
|
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||||
|
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
|
||||||
|
<p className="mt-2 text-center text-sm text-slate-500">
|
||||||
|
<span className="font-semibold">{t("common.upload_input_description")}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400">.json files only</p>
|
||||||
|
<Input
|
||||||
|
id="import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckIcon className="h-5 w-5 text-green-600" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{fileName}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
resetState();
|
||||||
|
document.getElementById("import-file-retry")?.click();
|
||||||
|
}}>
|
||||||
|
{t("environments.contacts.upload_contacts_modal_pick_different_file")}
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="import-file-retry"
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("environments.surveys.import_survey")}</DialogTitle>
|
||||||
|
<DialogDescription>{t("environments.surveys.import_survey_description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-md border-2 border-dashed border-slate-300 bg-slate-50 p-4">
|
||||||
|
{renderUploadSection()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationErrors.length > 0 && (
|
||||||
|
<Alert variant="error">
|
||||||
|
<AlertTitle>{t("environments.surveys.import_survey_errors")}</AlertTitle>
|
||||||
|
<AlertDescription className="max-h-60 overflow-y-auto">
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{validationErrors.map((error, i) => {
|
||||||
|
// Check if the error contains a field path (format: 'Field "path":')
|
||||||
|
const fieldMatch = error.match(/^Field "([^"]+)": (.+)$/);
|
||||||
|
if (fieldMatch) {
|
||||||
|
return (
|
||||||
|
<li key={i} className="flex flex-col gap-1">
|
||||||
|
<code className="rounded bg-red-50 px-1.5 py-0.5 font-mono text-xs text-red-800">
|
||||||
|
{fieldMatch[1]}
|
||||||
|
</code>
|
||||||
|
<span className="text-slate-700">{fieldMatch[2]}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<li key={i} className="text-slate-700">
|
||||||
|
{error}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationWarnings.length > 0 && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertTitle>{t("environments.surveys.import_survey_warnings")}</AlertTitle>
|
||||||
|
<AlertDescription className="max-h-60 overflow-y-auto">
|
||||||
|
<ul className="list-disc pl-4 text-sm">
|
||||||
|
{validationWarnings.map((warningKey, i) => (
|
||||||
|
<li key={i}>{t(`environments.surveys.${warningKey}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationInfos.length > 0 && (
|
||||||
|
<Alert variant="info">
|
||||||
|
<AlertDescription className="max-h-60 overflow-y-auto">
|
||||||
|
<ul className="list-disc pl-4 text-sm">
|
||||||
|
{validationInfos.map((infoKey, i) => (
|
||||||
|
<li key={i}>{t(`environments.surveys.${infoKey}`)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{isValid && fileName && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="survey-name">{t("environments.surveys.import_survey_name_label")}</Label>
|
||||||
|
<Input id="survey-name" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
loading={isLoading}
|
||||||
|
disabled={!isValid || !fileName || validationErrors.length > 0 || isLoading}>
|
||||||
|
{t("environments.surveys.import_survey_import")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrowUpFromLineIcon,
|
ArrowUpFromLineIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
@@ -22,8 +23,10 @@ import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
|||||||
import {
|
import {
|
||||||
copySurveyToOtherEnvironmentAction,
|
copySurveyToOtherEnvironmentAction,
|
||||||
deleteSurveyAction,
|
deleteSurveyAction,
|
||||||
|
exportSurveyAction,
|
||||||
getSurveyAction,
|
getSurveyAction,
|
||||||
} from "@/modules/survey/list/actions";
|
} from "@/modules/survey/list/actions";
|
||||||
|
import { downloadSurveyJson } from "@/modules/survey/list/lib/download-survey";
|
||||||
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
import { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import {
|
import {
|
||||||
@@ -55,7 +58,7 @@ export const SurveyDropDownMenu = ({
|
|||||||
onSurveysCopied,
|
onSurveysCopied,
|
||||||
}: SurveyDropDownMenuProps) => {
|
}: SurveyDropDownMenuProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||||
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
|
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
|
||||||
@@ -73,6 +76,7 @@ export const SurveyDropDownMenu = ({
|
|||||||
deleteSurvey(surveyId);
|
deleteSurvey(surveyId);
|
||||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
toast.error(t("environments.surveys.error_deleting_survey"));
|
toast.error(t("environments.surveys.error_deleting_survey"));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -83,7 +87,6 @@ export const SurveyDropDownMenu = ({
|
|||||||
try {
|
try {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDropDownOpen(false);
|
setIsDropDownOpen(false);
|
||||||
// For single-use surveys, this button is disabled, so we just copy the base link
|
|
||||||
const copiedLink = copySurveyLink(surveyLink);
|
const copiedLink = copySurveyLink(surveyLink);
|
||||||
navigator.clipboard.writeText(copiedLink);
|
navigator.clipboard.writeText(copiedLink);
|
||||||
toast.success(t("common.copied_to_clipboard"));
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
@@ -114,6 +117,7 @@ export const SurveyDropDownMenu = ({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
toast.error(t("environments.surveys.survey_duplication_error"));
|
toast.error(t("environments.surveys.survey_duplication_error"));
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -125,6 +129,32 @@ export const SurveyDropDownMenu = ({
|
|||||||
setIsCautionDialogOpen(true);
|
setIsCautionDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExportSurvey = async () => {
|
||||||
|
const exportPromise = exportSurveyAction({ surveyId: survey.id }).then((result) => {
|
||||||
|
if (result?.data) {
|
||||||
|
downloadSurveyJson(survey.name, JSON.stringify(result.data, null, 2));
|
||||||
|
return result.data;
|
||||||
|
} else if (result?.serverError) {
|
||||||
|
throw new Error(result.serverError);
|
||||||
|
}
|
||||||
|
throw new Error(t("environments.surveys.export_survey_error"));
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.promise(exportPromise, {
|
||||||
|
loading: t("environments.surveys.export_survey_loading"),
|
||||||
|
success: t("environments.surveys.export_survey_success"),
|
||||||
|
error: (err) => err.message || t("environments.surveys.export_survey_error"),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exportPromise;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
} finally {
|
||||||
|
setIsDropDownOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||||
@@ -185,6 +215,21 @@ export const SurveyDropDownMenu = ({
|
|||||||
</button>
|
</button>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleExportSurvey();
|
||||||
|
}}>
|
||||||
|
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("environments.surveys.export_survey")}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{survey.type === "link" && survey.status !== "draft" && (
|
{survey.type === "link" && survey.status !== "draft" && (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
@@ -229,7 +274,7 @@ export const SurveyDropDownMenu = ({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsDropDownOpen(false);
|
setIsDropDownOpen(false);
|
||||||
setDeleteDialogOpen(true);
|
setIsDeleteDialogOpen(true);
|
||||||
}}>
|
}}>
|
||||||
<TrashIcon className="mr-2 h-4 w-4" />
|
<TrashIcon className="mr-2 h-4 w-4" />
|
||||||
{t("common.delete")}
|
{t("common.delete")}
|
||||||
@@ -244,7 +289,7 @@ export const SurveyDropDownMenu = ({
|
|||||||
<DeleteDialog
|
<DeleteDialog
|
||||||
deleteWhat="Survey"
|
deleteWhat="Survey"
|
||||||
open={isDeleteDialogOpen}
|
open={isDeleteDialogOpen}
|
||||||
setOpen={setDeleteDialogOpen}
|
setOpen={setIsDeleteDialogOpen}
|
||||||
onDelete={() => handleDeleteSurvey(survey.id)}
|
onDelete={() => handleDeleteSurvey(survey.id)}
|
||||||
text={t("environments.surveys.delete_survey_and_responses_warning")}
|
text={t("environments.surveys.delete_survey_and_responses_warning")}
|
||||||
isDeleting={loading}
|
isDeleting={loading}
|
||||||
|
|||||||
10
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
10
apps/web/modules/survey/list/lib/download-survey.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const downloadSurveyJson = (surveyName: string, jsonContent: string) => {
|
||||||
|
const blob = new Blob([jsonContent], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
const timestamp = new Date().toISOString().split("T")[0];
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${surveyName}-export-${timestamp}.json`;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
145
apps/web/modules/survey/list/lib/export-survey.ts
Normal file
145
apps/web/modules/survey/list/lib/export-survey.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ZSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||||
|
import { ZActionClassNoCodeConfig, ZActionClassType } from "@formbricks/types/action-classes";
|
||||||
|
import { type TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
|
// Schema for exported action class (subset of full action class)
|
||||||
|
export const ZExportedActionClass = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string().nullable(),
|
||||||
|
type: ZActionClassType,
|
||||||
|
key: z.string().nullable(),
|
||||||
|
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TExportedActionClass = z.infer<typeof ZExportedActionClass>;
|
||||||
|
|
||||||
|
// Schema for exported trigger
|
||||||
|
export const ZExportedTrigger = z.object({
|
||||||
|
actionClass: ZExportedActionClass,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TExportedTrigger = z.infer<typeof ZExportedTrigger>;
|
||||||
|
|
||||||
|
// Schema for exported language
|
||||||
|
export const ZExportedLanguage = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
default: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TExportedLanguage = z.infer<typeof ZExportedLanguage>;
|
||||||
|
|
||||||
|
// Current export format version
|
||||||
|
export const SURVEY_EXPORT_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
// Survey data schema - the actual survey content (nested under "data" in export)
|
||||||
|
export const ZSurveyExportData = z.object({
|
||||||
|
// Use the input shape from ZSurveyCreateInput and override what we need
|
||||||
|
name: z.string(),
|
||||||
|
type: z.string().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
displayOption: z.string().optional(),
|
||||||
|
environmentId: z.string().optional(),
|
||||||
|
createdBy: z.string().optional(),
|
||||||
|
autoClose: z.number().nullable().optional(),
|
||||||
|
recontactDays: z.number().nullable().optional(),
|
||||||
|
displayLimit: z.number().nullable().optional(),
|
||||||
|
delay: z.number().optional(),
|
||||||
|
displayPercentage: z.number().nullable().optional(),
|
||||||
|
autoComplete: z.number().nullable().optional(),
|
||||||
|
isVerifyEmailEnabled: z.boolean().optional(),
|
||||||
|
isSingleResponsePerEmailEnabled: z.boolean().optional(),
|
||||||
|
isBackButtonHidden: z.boolean().optional(),
|
||||||
|
pin: z.string().nullable().optional(),
|
||||||
|
welcomeCard: z.any().optional(),
|
||||||
|
blocks: z.array(z.any()),
|
||||||
|
endings: z.array(z.any()).optional(),
|
||||||
|
hiddenFields: z.any().optional(),
|
||||||
|
variables: z.array(z.any()).optional(),
|
||||||
|
surveyClosedMessage: z.any().optional(),
|
||||||
|
styling: z.any().optional(),
|
||||||
|
showLanguageSwitch: z.boolean().nullable().optional(),
|
||||||
|
recaptcha: z.any().optional(),
|
||||||
|
metadata: z.any().optional(),
|
||||||
|
triggers: z.array(ZExportedTrigger).default([]),
|
||||||
|
languages: z.array(ZExportedLanguage).default([]),
|
||||||
|
followUps: z.array(ZSurveyFollowUp.omit({ createdAt: true, updatedAt: true })).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSurveyExportData = z.infer<typeof ZSurveyExportData>;
|
||||||
|
|
||||||
|
// Full export payload with version and metadata wrapper
|
||||||
|
export const ZSurveyExportPayload = z.object({
|
||||||
|
version: z.string(),
|
||||||
|
exportDate: z.string().datetime(),
|
||||||
|
data: ZSurveyExportData,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TSurveyExportPayload = z.infer<typeof ZSurveyExportPayload>;
|
||||||
|
|
||||||
|
export const transformSurveyForExport = (survey: TSurvey): TSurveyExportPayload => {
|
||||||
|
const surveyData: TSurveyExportData = {
|
||||||
|
name: survey.name,
|
||||||
|
type: survey.type,
|
||||||
|
status: survey.status,
|
||||||
|
displayOption: survey.displayOption,
|
||||||
|
autoClose: survey.autoClose,
|
||||||
|
recontactDays: survey.recontactDays,
|
||||||
|
displayLimit: survey.displayLimit,
|
||||||
|
delay: survey.delay,
|
||||||
|
displayPercentage: survey.displayPercentage,
|
||||||
|
autoComplete: survey.autoComplete,
|
||||||
|
isVerifyEmailEnabled: survey.isVerifyEmailEnabled,
|
||||||
|
isSingleResponsePerEmailEnabled: survey.isSingleResponsePerEmailEnabled,
|
||||||
|
isBackButtonHidden: survey.isBackButtonHidden,
|
||||||
|
pin: survey.pin,
|
||||||
|
welcomeCard: survey.welcomeCard,
|
||||||
|
blocks: survey.blocks,
|
||||||
|
endings: survey.endings,
|
||||||
|
hiddenFields: survey.hiddenFields,
|
||||||
|
variables: survey.variables,
|
||||||
|
surveyClosedMessage: survey.surveyClosedMessage,
|
||||||
|
styling: survey.styling,
|
||||||
|
showLanguageSwitch: survey.showLanguageSwitch,
|
||||||
|
recaptcha: survey.recaptcha,
|
||||||
|
metadata: survey.metadata,
|
||||||
|
|
||||||
|
triggers:
|
||||||
|
survey.triggers?.map(
|
||||||
|
(t): TExportedTrigger => ({
|
||||||
|
actionClass: {
|
||||||
|
name: t.actionClass.name,
|
||||||
|
description: t.actionClass.description,
|
||||||
|
type: t.actionClass.type,
|
||||||
|
key: t.actionClass.key,
|
||||||
|
noCodeConfig: t.actionClass.noCodeConfig,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
) ?? [],
|
||||||
|
|
||||||
|
languages:
|
||||||
|
survey.languages?.map(
|
||||||
|
(l): TExportedLanguage => ({
|
||||||
|
enabled: l.enabled,
|
||||||
|
default: l.default,
|
||||||
|
code: l.language.code,
|
||||||
|
})
|
||||||
|
) ?? [],
|
||||||
|
|
||||||
|
followUps:
|
||||||
|
survey.followUps?.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
surveyId: f.surveyId,
|
||||||
|
name: f.name,
|
||||||
|
trigger: f.trigger,
|
||||||
|
action: f.action,
|
||||||
|
})) ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: SURVEY_EXPORT_VERSION,
|
||||||
|
exportDate: new Date().toISOString(),
|
||||||
|
data: surveyData,
|
||||||
|
};
|
||||||
|
};
|
||||||
119
apps/web/modules/survey/list/lib/import-helpers.ts
Normal file
119
apps/web/modules/survey/list/lib/import-helpers.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
|
import { iso639Languages } from "@/lib/i18n/utils";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||||
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
|
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||||
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
|
import { type TExportedLanguage, type TExportedTrigger } from "./export-survey";
|
||||||
|
import {
|
||||||
|
type TMappedTrigger,
|
||||||
|
type TSurveyLanguageConnection,
|
||||||
|
mapLanguages,
|
||||||
|
mapTriggers,
|
||||||
|
normalizeLanguagesForCreation,
|
||||||
|
resolveImportCapabilities,
|
||||||
|
stripUnavailableFeatures as stripFeatures,
|
||||||
|
} from "./import";
|
||||||
|
|
||||||
|
export const getLanguageNames = (languageCodes: string[]): string[] => {
|
||||||
|
return languageCodes.map((code) => {
|
||||||
|
const language = iso639Languages.find((lang) => lang.alpha2 === code);
|
||||||
|
return language ? language.label["en-US"] : code;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapExportedLanguagesToPrismaCreate = async (
|
||||||
|
exportedLanguages: TExportedLanguage[],
|
||||||
|
projectId: string
|
||||||
|
): Promise<TSurveyLanguageConnection | undefined> => {
|
||||||
|
const result = await mapLanguages(exportedLanguages, projectId);
|
||||||
|
return normalizeLanguagesForCreation(result.mapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapOrCreateActionClasses = async (
|
||||||
|
importedTriggers: TExportedTrigger[],
|
||||||
|
environmentId: string
|
||||||
|
): Promise<TMappedTrigger[]> => {
|
||||||
|
const result = await mapTriggers(importedTriggers, environmentId);
|
||||||
|
return result.mapped;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stripUnavailableFeatures = async (
|
||||||
|
survey: TSurveyCreateInput,
|
||||||
|
environmentId: string
|
||||||
|
): Promise<TSurveyCreateInput> => {
|
||||||
|
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||||
|
const capabilities = await resolveImportCapabilities(organizationId);
|
||||||
|
return stripFeatures(survey, capabilities);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildImportWarnings = async (
|
||||||
|
survey: TSurveyCreateInput,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
if (survey.languages?.length) {
|
||||||
|
try {
|
||||||
|
await checkMultiLanguagePermission(organizationId);
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push("import_warning_multi_language");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.followUps?.length) {
|
||||||
|
let hasFollowUps = false;
|
||||||
|
try {
|
||||||
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
|
if (organizationBilling) {
|
||||||
|
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
if (!hasFollowUps) {
|
||||||
|
warnings.push("import_warning_follow_ups");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.recaptcha?.enabled) {
|
||||||
|
try {
|
||||||
|
await checkSpamProtectionPermission(organizationId);
|
||||||
|
} catch (e) {
|
||||||
|
warnings.push("import_warning_recaptcha");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.segment) {
|
||||||
|
warnings.push("import_warning_segments");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.triggers?.length) {
|
||||||
|
warnings.push("import_warning_action_classes");
|
||||||
|
}
|
||||||
|
|
||||||
|
return warnings;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectImagesInSurvey = (survey: TSurveyCreateInput): boolean => {
|
||||||
|
if (survey.welcomeCard?.fileUrl || survey.welcomeCard?.videoUrl) return true;
|
||||||
|
|
||||||
|
// Check blocks for images
|
||||||
|
if (survey.blocks) {
|
||||||
|
for (const block of survey.blocks) {
|
||||||
|
for (const element of block.elements) {
|
||||||
|
if (element.imageUrl || element.videoUrl) return true;
|
||||||
|
if (element.type === "pictureSelection" && element.choices?.some((c) => c.imageUrl)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.endings && survey.endings.length > 0) {
|
||||||
|
for (const e of survey.endings) {
|
||||||
|
if (e.type === "endScreen" && (e.imageUrl || e.videoUrl)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
11
apps/web/modules/survey/list/lib/import/index.ts
Normal file
11
apps/web/modules/survey/list/lib/import/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { mapLanguages, type TMappedLanguage } from "./map-languages";
|
||||||
|
export { mapTriggers, type TMappedTrigger } from "./map-triggers";
|
||||||
|
export {
|
||||||
|
addLanguageLabels,
|
||||||
|
normalizeLanguagesForCreation,
|
||||||
|
stripUnavailableFeatures,
|
||||||
|
type TSurveyLanguageConnection,
|
||||||
|
} from "./normalize-survey";
|
||||||
|
export { parseSurveyPayload, type TParsedPayload } from "./parse-payload";
|
||||||
|
export { resolveImportCapabilities, type TImportCapabilities } from "./permissions";
|
||||||
|
export { persistSurvey } from "./persist-survey";
|
||||||
41
apps/web/modules/survey/list/lib/import/map-languages.ts
Normal file
41
apps/web/modules/survey/list/lib/import/map-languages.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { getProject } from "@/lib/project/service";
|
||||||
|
import { type TExportedLanguage } from "../export-survey";
|
||||||
|
|
||||||
|
export interface TMappedLanguage {
|
||||||
|
languageId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
default: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapLanguages = async (
|
||||||
|
exportedLanguages: TExportedLanguage[],
|
||||||
|
projectId: string
|
||||||
|
): Promise<{ mapped: TMappedLanguage[]; skipped: string[] }> => {
|
||||||
|
if (!exportedLanguages || exportedLanguages.length === 0) {
|
||||||
|
return { mapped: [], skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await getProject(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return { mapped: [], skipped: ["Project not found"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedLanguages: TMappedLanguage[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const exportedLang of exportedLanguages) {
|
||||||
|
const projectLanguage = project.languages.find((l) => l.code === exportedLang.code);
|
||||||
|
if (!projectLanguage) {
|
||||||
|
skipped.push(`Language ${exportedLang.code} not found in project`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedLanguages.push({
|
||||||
|
languageId: projectLanguage.id,
|
||||||
|
enabled: exportedLang.enabled,
|
||||||
|
default: exportedLang.default,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mapped: mappedLanguages, skipped };
|
||||||
|
};
|
||||||
55
apps/web/modules/survey/list/lib/import/map-triggers.ts
Normal file
55
apps/web/modules/survey/list/lib/import/map-triggers.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { TActionClassInput } from "@formbricks/types/action-classes";
|
||||||
|
import { createActionClass } from "@/modules/survey/editor/lib/action-class";
|
||||||
|
import { getActionClasses } from "@/modules/survey/lib/action-class";
|
||||||
|
import { type TExportedTrigger } from "../export-survey";
|
||||||
|
|
||||||
|
export interface TMappedTrigger {
|
||||||
|
actionClass: { id: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mapTriggers = async (
|
||||||
|
importedTriggers: TExportedTrigger[],
|
||||||
|
environmentId: string
|
||||||
|
): Promise<{ mapped: TMappedTrigger[]; skipped: string[] }> => {
|
||||||
|
if (!importedTriggers || importedTriggers.length === 0) {
|
||||||
|
return { mapped: [], skipped: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingActionClasses = await getActionClasses(environmentId);
|
||||||
|
const mappedTriggers: TMappedTrigger[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
|
||||||
|
for (const trigger of importedTriggers) {
|
||||||
|
const ac = trigger.actionClass;
|
||||||
|
|
||||||
|
let existing = existingActionClasses.find((e) => e.key === ac.key && e.type === ac.type);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
try {
|
||||||
|
const actionClassInput: TActionClassInput = {
|
||||||
|
environmentId,
|
||||||
|
name: `${ac.name} (imported)`,
|
||||||
|
description: ac.description ?? null,
|
||||||
|
type: ac.type,
|
||||||
|
key: ac.key,
|
||||||
|
noCodeConfig: ac.noCodeConfig,
|
||||||
|
};
|
||||||
|
existing = await createActionClass(environmentId, actionClassInput);
|
||||||
|
} catch (error) {
|
||||||
|
existing = await getActionClasses(environmentId).then((classes) =>
|
||||||
|
classes.find((e) => e.key === ac.key && e.type === ac.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
mappedTriggers.push({
|
||||||
|
actionClass: { id: existing.id },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
skipped.push(`Could not find or create action class: ${ac.name} (${ac.key ?? "no key"})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mapped: mappedTriggers, skipped };
|
||||||
|
};
|
||||||
60
apps/web/modules/survey/list/lib/import/normalize-survey.ts
Normal file
60
apps/web/modules/survey/list/lib/import/normalize-survey.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
|
import { addMultiLanguageLabels } from "@/lib/i18n/utils";
|
||||||
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
|
import { type TMappedLanguage } from "./map-languages";
|
||||||
|
import { type TImportCapabilities } from "./permissions";
|
||||||
|
|
||||||
|
export const stripUnavailableFeatures = (
|
||||||
|
survey: TSurveyCreateInput,
|
||||||
|
capabilities: TImportCapabilities
|
||||||
|
): TSurveyCreateInput => {
|
||||||
|
const cloned = structuredClone(survey);
|
||||||
|
|
||||||
|
if (!capabilities.hasMultiLanguage) {
|
||||||
|
cloned.languages = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilities.hasFollowUps) {
|
||||||
|
cloned.followUps = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!capabilities.hasRecaptcha) {
|
||||||
|
cloned.recaptcha = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete cloned.segment;
|
||||||
|
|
||||||
|
return cloned;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TSurveyLanguageConnection {
|
||||||
|
create: { languageId: string; enabled: boolean; default: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeLanguagesForCreation = (
|
||||||
|
languages: TMappedLanguage[]
|
||||||
|
): TSurveyLanguageConnection | undefined => {
|
||||||
|
if (!languages || languages.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
create: languages.map((lang) => ({
|
||||||
|
languageId: lang.languageId,
|
||||||
|
enabled: lang.enabled,
|
||||||
|
default: lang.default,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addLanguageLabels = (
|
||||||
|
survey: TSurveyCreateInput,
|
||||||
|
languageCodes: string[]
|
||||||
|
): TSurveyCreateInput => {
|
||||||
|
if (!languageCodes || languageCodes.length === 0) {
|
||||||
|
return survey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = structuredClone(survey);
|
||||||
|
return addMultiLanguageLabels(cloned, languageCodes);
|
||||||
|
};
|
||||||
98
apps/web/modules/survey/list/lib/import/parse-payload.ts
Normal file
98
apps/web/modules/survey/list/lib/import/parse-payload.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { TSurveyCreateInput, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
|
import {
|
||||||
|
SURVEY_EXPORT_VERSION,
|
||||||
|
type TExportedLanguage,
|
||||||
|
type TExportedTrigger,
|
||||||
|
ZExportedLanguage,
|
||||||
|
ZExportedTrigger,
|
||||||
|
ZSurveyExportPayload,
|
||||||
|
} from "../export-survey";
|
||||||
|
|
||||||
|
export interface TParsedPayload {
|
||||||
|
surveyInput: TSurveyCreateInput;
|
||||||
|
exportedLanguages: TExportedLanguage[];
|
||||||
|
triggers: TExportedTrigger[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TParseError {
|
||||||
|
error: string;
|
||||||
|
details?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseSurveyPayload = (surveyData: unknown): TParsedPayload | TParseError => {
|
||||||
|
if (typeof surveyData !== "object" || surveyData === null) {
|
||||||
|
return { error: "Invalid survey data: expected an object" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let actualSurveyData: Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check if this is the new versioned format (with version, exportDate, and data wrapper)
|
||||||
|
const versionedFormatCheck = ZSurveyExportPayload.safeParse(surveyData);
|
||||||
|
if (versionedFormatCheck.success) {
|
||||||
|
// New format: extract the data from the wrapper
|
||||||
|
const { version, data } = versionedFormatCheck.data;
|
||||||
|
|
||||||
|
// Validate version (for future compatibility)
|
||||||
|
if (version !== SURVEY_EXPORT_VERSION) {
|
||||||
|
console.warn(
|
||||||
|
`Import: Survey export version ${version} differs from current version ${SURVEY_EXPORT_VERSION}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
actualSurveyData = data as Record<string, unknown>;
|
||||||
|
} else {
|
||||||
|
// Legacy format or pre-versioning format: use data as-is
|
||||||
|
actualSurveyData = surveyData as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const surveyDataCopy = { ...actualSurveyData } as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Validate and extract languages
|
||||||
|
const languagesResult = z.array(ZExportedLanguage).safeParse(surveyDataCopy.languages ?? []);
|
||||||
|
if (!languagesResult.success) {
|
||||||
|
return {
|
||||||
|
error: "Invalid languages format",
|
||||||
|
details: languagesResult.error.errors.map((e) => {
|
||||||
|
const path = e.path.length > 0 ? `languages.${e.path.join(".")}` : "languages";
|
||||||
|
return `Field "${path}": ${e.message}`;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const exportedLanguages = languagesResult.data;
|
||||||
|
|
||||||
|
// Validate and extract triggers
|
||||||
|
const triggersResult = z.array(ZExportedTrigger).safeParse(surveyDataCopy.triggers ?? []);
|
||||||
|
if (!triggersResult.success) {
|
||||||
|
return {
|
||||||
|
error: "Invalid triggers format",
|
||||||
|
details: triggersResult.error.errors.map((e) => {
|
||||||
|
const path = e.path.length > 0 ? `triggers.${e.path.join(".")}` : "triggers";
|
||||||
|
return `Field "${path}": ${e.message}`;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const triggers = triggersResult.data;
|
||||||
|
|
||||||
|
// Remove these from the copy before validating against ZSurveyCreateInput
|
||||||
|
delete surveyDataCopy.languages;
|
||||||
|
delete surveyDataCopy.triggers;
|
||||||
|
|
||||||
|
// Validate the main survey structure
|
||||||
|
const surveyResult = ZSurveyCreateInput.safeParse(surveyDataCopy);
|
||||||
|
if (!surveyResult.success) {
|
||||||
|
return {
|
||||||
|
error: "Invalid survey format",
|
||||||
|
details: surveyResult.error.errors.map((e) => {
|
||||||
|
const path = e.path.length > 0 ? e.path.join(".") : "root";
|
||||||
|
return `Field "${path}": ${e.message}`;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
surveyInput: surveyResult.data,
|
||||||
|
exportedLanguages,
|
||||||
|
triggers,
|
||||||
|
};
|
||||||
|
};
|
||||||
34
apps/web/modules/survey/list/lib/import/permissions.ts
Normal file
34
apps/web/modules/survey/list/lib/import/permissions.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
|
||||||
|
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||||
|
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
|
||||||
|
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||||
|
|
||||||
|
export interface TImportCapabilities {
|
||||||
|
hasMultiLanguage: boolean;
|
||||||
|
hasFollowUps: boolean;
|
||||||
|
hasRecaptcha: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveImportCapabilities = async (organizationId: string): Promise<TImportCapabilities> => {
|
||||||
|
let hasMultiLanguage = false;
|
||||||
|
try {
|
||||||
|
await checkMultiLanguagePermission(organizationId);
|
||||||
|
hasMultiLanguage = true;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let hasFollowUps = false;
|
||||||
|
try {
|
||||||
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
|
if (organizationBilling) {
|
||||||
|
hasFollowUps = await getSurveyFollowUpsPermission(organizationBilling.plan);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
let hasRecaptcha = false;
|
||||||
|
try {
|
||||||
|
await checkSpamProtectionPermission(organizationId);
|
||||||
|
hasRecaptcha = true;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
return { hasMultiLanguage, hasFollowUps, hasRecaptcha };
|
||||||
|
};
|
||||||
34
apps/web/modules/survey/list/lib/import/persist-survey.ts
Normal file
34
apps/web/modules/survey/list/lib/import/persist-survey.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||||
|
import { createSurvey } from "@/modules/survey/components/template-list/lib/survey";
|
||||||
|
import { type TMappedTrigger } from "./map-triggers";
|
||||||
|
import { type TSurveyLanguageConnection } from "./normalize-survey";
|
||||||
|
|
||||||
|
export const persistSurvey = async (
|
||||||
|
environmentId: string,
|
||||||
|
survey: TSurveyCreateInput,
|
||||||
|
newName: string,
|
||||||
|
createdBy: string,
|
||||||
|
mappedTriggers: TMappedTrigger[],
|
||||||
|
mappedLanguages?: TSurveyLanguageConnection
|
||||||
|
): Promise<{ surveyId: string }> => {
|
||||||
|
const followUpsWithNewIds = survey.followUps?.map((f) => ({
|
||||||
|
...f,
|
||||||
|
id: createId(),
|
||||||
|
surveyId: createId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const surveyToCreate = {
|
||||||
|
...survey,
|
||||||
|
name: newName,
|
||||||
|
status: "draft" as const,
|
||||||
|
triggers: mappedTriggers as any, // Type system expects full ActionClass, but createSurvey only uses the id
|
||||||
|
followUps: followUpsWithNewIds,
|
||||||
|
createdBy,
|
||||||
|
...(mappedLanguages && { languages: mappedLanguages as any }), // Prisma nested create format
|
||||||
|
} as TSurveyCreateInput;
|
||||||
|
|
||||||
|
const newSurvey = await createSurvey(environmentId, surveyToCreate);
|
||||||
|
|
||||||
|
return { surveyId: newSurvey.id };
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import { getUserLocale } from "@/lib/user/service";
|
|||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
import { getProjectWithTeamIdsByEnvironmentId } from "@/modules/survey/lib/project";
|
||||||
|
import { ImportSurveyButton } from "@/modules/survey/list/components/import-survey-button";
|
||||||
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
import { SurveysList } from "@/modules/survey/list/components/survey-list";
|
||||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||||
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
import { TemplateContainerWithPreview } from "@/modules/survey/templates/components/template-container";
|
||||||
@@ -46,14 +47,17 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
|||||||
|
|
||||||
const currentProjectChannel = project.config.channel ?? null;
|
const currentProjectChannel = project.config.channel ?? null;
|
||||||
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
|
const locale = (await getUserLocale(session.user.id)) ?? DEFAULT_LOCALE;
|
||||||
const CreateSurveyButton = () => {
|
const SurveyListCTA = () => {
|
||||||
return (
|
return (
|
||||||
<Button size="sm" asChild>
|
<div className="flex gap-2">
|
||||||
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
<ImportSurveyButton environmentId={environment.id} />
|
||||||
{t("environments.surveys.new_survey")}
|
<Button size="sm" asChild>
|
||||||
<PlusIcon />
|
<Link href={`/environments/${environment.id}/surveys/templates`}>
|
||||||
</Link>
|
{t("environments.surveys.new_survey")}
|
||||||
</Button>
|
<PlusIcon />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,7 +81,7 @@ export const SurveysPage = async ({ params: paramsProps }: SurveyTemplateProps)
|
|||||||
if (surveyCount > 0) {
|
if (surveyCount > 0) {
|
||||||
content = (
|
content = (
|
||||||
<>
|
<>
|
||||||
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <CreateSurveyButton />} />
|
<PageHeader pageTitle={t("common.surveys")} cta={isReadOnly ? <></> : <SurveyListCTA />} />
|
||||||
<SurveysList
|
<SurveysList
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ interface ClientLogoProps {
|
|||||||
previewSurvey?: boolean;
|
previewSurvey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => {
|
export const ClientLogo = ({
|
||||||
|
environmentId,
|
||||||
|
projectLogo,
|
||||||
|
surveyLogo,
|
||||||
|
previewSurvey = false,
|
||||||
|
}: ClientLogoProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
|
const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user