mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-10 03:08:50 -06:00
Compare commits
9 Commits
fix/allow-
...
typeerror-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a509f73eda | ||
|
|
bbcb48cb1c | ||
|
|
34e04f2c8b | ||
|
|
07a6cd6c0e | ||
|
|
335da2f1f5 | ||
|
|
13b9db915b | ||
|
|
76b25476b3 | ||
|
|
04220902b4 | ||
|
|
4649a2de3e |
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { deleteOrganizationAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
@@ -32,7 +33,12 @@ export const DeleteOrganization = ({
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await deleteOrganizationAction({ organizationId: organization.id });
|
||||
const result = await deleteOrganizationAction({ organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.organization_deleted_successfully"));
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
@@ -268,7 +269,14 @@ export const AddIntegrationModal = ({
|
||||
airtableIntegrationData.config?.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: airtableIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: airtableIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (isEditMode) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -304,7 +312,11 @@ export const AddIntegrationModal = ({
|
||||
const integrationData = structuredClone(airtableIntegrationData);
|
||||
integrationData.config.data.splice(index, 1);
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
router.refresh();
|
||||
|
||||
|
||||
@@ -165,7 +165,14 @@ export const AddIntegrationModal = ({
|
||||
// create action
|
||||
googleSheetIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -205,7 +212,14 @@ export const AddIntegrationModal = ({
|
||||
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: googleSheetIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
@@ -266,7 +280,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
createEmptyMapping,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -217,7 +218,14 @@ export const AddIntegrationModal = ({
|
||||
notionIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -236,7 +244,14 @@ export const AddIntegrationModal = ({
|
||||
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: notionIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
@@ -144,7 +145,14 @@ export const AddChannelMappingModal = ({
|
||||
// create action
|
||||
slackIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: slackIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
if (selectedIntegration) {
|
||||
toast.success(t("environments.integrations.integration_updated_successfully"));
|
||||
} else {
|
||||
@@ -181,7 +189,14 @@ export const AddChannelMappingModal = ({
|
||||
slackIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: slackIntegrationData });
|
||||
const result = await createOrUpdateIntegrationAction({
|
||||
environmentId,
|
||||
integrationData: slackIntegrationData,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.integrations.integration_removed_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
|
||||
@@ -141,5 +141,52 @@ describe("Time Utilities", () => {
|
||||
expect(convertDatesInObject("string")).toBe("string");
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("should not convert dates in contactAttributes", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.contactAttributes.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("should not convert dates in variables", () => {
|
||||
const input = {
|
||||
updatedAt: "2024-03-20T15:30:00",
|
||||
variables: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
userId: "123",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.variables.userId).toBe("123");
|
||||
});
|
||||
|
||||
test("should not convert dates in data or meta", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
data: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
},
|
||||
meta: {
|
||||
updatedAt: "2024-03-20T17:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -160,7 +160,12 @@ export const convertDatesInObject = <T>(obj: T): T => {
|
||||
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
|
||||
}
|
||||
const newObj: any = {};
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
for (const key in obj) {
|
||||
if (keysToIgnore.has(key)) {
|
||||
newObj[key] = obj[key];
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
(key === "createdAt" || key === "updatedAt") &&
|
||||
typeof obj[key] === "string" &&
|
||||
|
||||
@@ -109,7 +109,13 @@ export function SegmentSettings({
|
||||
const handleDeleteSegment = async () => {
|
||||
try {
|
||||
setIsDeletingSegment(true);
|
||||
await deleteSegmentAction({ segmentId: segment.id });
|
||||
const result = await deleteSegmentAction({ segmentId: segment.id });
|
||||
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeletingSegment(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeletingSegment(false);
|
||||
toast.success(t("environments.segments.segment_deleted_successfully"));
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
cloneSegmentAction,
|
||||
createSegmentAction,
|
||||
@@ -135,7 +136,11 @@ export function TargetingCard({
|
||||
const handleSaveSegment = async (data: TSegmentUpdateInput) => {
|
||||
try {
|
||||
if (!segment) throw new Error(t("environments.segments.invalid_segment"));
|
||||
await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
const result = await updateSegmentAction({ segmentId: segment.id, environmentId, data });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.segments.segment_saved_successfully"));
|
||||
|
||||
setIsSegmentEditorOpen(false);
|
||||
|
||||
@@ -154,7 +154,12 @@ export function EditLanguage({
|
||||
|
||||
const performLanguageDeletion = async (languageId: string) => {
|
||||
try {
|
||||
await deleteLanguageAction({ languageId, projectId: project.id });
|
||||
const result = await deleteLanguageAction({ languageId, projectId: project.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setConfirmationModal((prev) => ({ ...prev, isOpen: false }));
|
||||
return;
|
||||
}
|
||||
setLanguages((prev) => prev.filter((lang) => lang.id !== languageId));
|
||||
toast.success(t("environments.workspace.languages.language_deleted_successfully"));
|
||||
// Close the modal after deletion
|
||||
@@ -187,7 +192,7 @@ export function EditLanguage({
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!validateLanguages(languages, t)) return;
|
||||
await Promise.all(
|
||||
const results = await Promise.all(
|
||||
languages.map((lang) => {
|
||||
return lang.id === "new"
|
||||
? createLanguageAction({
|
||||
@@ -201,6 +206,11 @@ export function EditLanguage({
|
||||
});
|
||||
})
|
||||
);
|
||||
const errorResult = results.find((result) => result?.serverError);
|
||||
if (errorResult) {
|
||||
toast.error(getFormattedErrorMessage(errorResult));
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.workspace.languages.languages_updated_successfully"));
|
||||
router.refresh();
|
||||
setIsEditing(false);
|
||||
@@ -239,7 +249,7 @@ export function EditLanguage({
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 italic">
|
||||
<p className="text-sm italic text-slate-500">
|
||||
{t("environments.workspace.languages.no_language_found")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { deleteTeamAction } from "@/modules/ee/teams/team-list/actions";
|
||||
import { TTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -27,6 +28,12 @@ export const DeleteTeam = ({ teamId, onDelete, isOwnerOrManager }: DeleteTeamPro
|
||||
setIsDeleting(true);
|
||||
|
||||
const deleteTeamActionResponse = await deleteTeamAction({ teamId });
|
||||
if (deleteTeamActionResponse?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(deleteTeamActionResponse));
|
||||
setIsDeleteDialogOpen(false);
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
if (deleteTeamActionResponse?.data) {
|
||||
toast.success(t("environments.settings.teams.team_deleted_successfully"));
|
||||
onDelete?.();
|
||||
|
||||
@@ -42,14 +42,27 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
|
||||
if (!member && invite) {
|
||||
// This is an invite
|
||||
|
||||
await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
const result = await deleteInviteAction({ inviteId: invite?.id, organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.invite_deleted_successfully"));
|
||||
}
|
||||
|
||||
if (member && !invite) {
|
||||
// This is a member
|
||||
|
||||
await deleteMembershipAction({ userId: member.userId, organizationId: organization.id });
|
||||
const result = await deleteMembershipAction({
|
||||
userId: member.userId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setIsDeleting(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.member_deleted_successfully"));
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,12 @@ export const OrganizationActions = ({
|
||||
const handleLeaveOrganization = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await leaveOrganizationAction({ organizationId: organization.id });
|
||||
const result = await leaveOrganizationAction({ organizationId: organization.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
toast.success(t("environments.settings.general.member_deleted_successfully"));
|
||||
router.refresh();
|
||||
setLoading(false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
@@ -92,10 +93,14 @@ export const ActionSettingsTab = ({
|
||||
validatePermissions(isReadOnly, t);
|
||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
||||
|
||||
await updateActionClassAction({
|
||||
const result = await updateActionClassAction({
|
||||
actionClassId: actionClass.id,
|
||||
updatedAction: updatedAction,
|
||||
});
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_updated_successfully"));
|
||||
@@ -109,7 +114,11 @@ export const ActionSettingsTab = ({
|
||||
const handleDeleteAction = async () => {
|
||||
try {
|
||||
setIsDeletingAction(true);
|
||||
await deleteActionClassAction({ actionClassId: actionClass.id });
|
||||
const result = await deleteActionClassAction({ actionClassId: actionClass.id });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||
setOpen(false);
|
||||
|
||||
@@ -69,6 +69,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.headline?.default || endingCard.headline.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
<div>
|
||||
{endingCard.subheader !== undefined && (
|
||||
@@ -87,6 +88,7 @@ export const EndScreenForm = ({
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
autoFocus={!endingCard.subheader?.default || endingCard.subheader.default.trim() === ""}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ interface PictureSelectionFormProps {
|
||||
isInvalid: boolean;
|
||||
locale: TUserLocale;
|
||||
isStorageConfigured: boolean;
|
||||
isExternalUrlsAllowed?: boolean;
|
||||
}
|
||||
|
||||
export const PictureSelectionForm = ({
|
||||
@@ -39,6 +40,7 @@ export const PictureSelectionForm = ({
|
||||
isInvalid,
|
||||
locale,
|
||||
isStorageConfigured = true,
|
||||
isExternalUrlsAllowed,
|
||||
}: PictureSelectionFormProps): JSX.Element => {
|
||||
const environmentId = localSurvey.environmentId;
|
||||
const surveyLanguageCodes = extractLanguageCodes(localSurvey.languages);
|
||||
@@ -88,6 +90,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.headline?.default || element.headline.default.trim() === ""}
|
||||
/>
|
||||
<div ref={parent}>
|
||||
@@ -106,6 +109,7 @@ export const PictureSelectionForm = ({
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
locale={locale}
|
||||
isStorageConfigured={isStorageConfigured}
|
||||
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||
autoFocus={!element.subheader?.default || element.subheader.default.trim() === ""}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -188,6 +188,8 @@ export const FollowUpModal = ({
|
||||
subject: defaultValues?.subject ?? t("environments.surveys.edit.follow_ups_modal_action_subject"),
|
||||
body: defaultValues?.body ?? getSurveyFollowUpActionDefaultBody(t),
|
||||
attachResponseData: defaultValues?.attachResponseData ?? false,
|
||||
includeVariables: defaultValues?.includeVariables ?? false,
|
||||
includeHiddenFields: defaultValues?.includeHiddenFields ?? false,
|
||||
},
|
||||
resolver: zodResolver(ZCreateSurveyFollowUpFormSchema),
|
||||
mode: "onChange",
|
||||
|
||||
@@ -69,7 +69,11 @@ export const SurveyDropDownMenu = ({
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction({ surveyId });
|
||||
const result = await deleteSurveyAction({ surveyId });
|
||||
if (result?.serverError) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
deleteSurvey(surveyId);
|
||||
toast.success(t("environments.surveys.survey_deleted_successfully"));
|
||||
} catch (error) {
|
||||
|
||||
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
107
apps/web/playwright/survey-follow-up.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Survey Follow-Up Create & Edit", async () => {
|
||||
// 3 minutes
|
||||
test.setTimeout(1000 * 60 * 3);
|
||||
|
||||
test("Create a follow-up without optional toggles and verify it saves", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.login();
|
||||
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
await test.step("Create a new survey", async () => {
|
||||
await page.getByText("Start from scratch").click();
|
||||
await page.getByRole("button", { name: "Create survey", exact: true }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/);
|
||||
});
|
||||
|
||||
await test.step("Navigate to Follow-ups tab", async () => {
|
||||
await page.getByText("Follow-ups").click();
|
||||
// Verify the empty state is shown
|
||||
await expect(page.getByText("Send automatic follow-ups")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a new follow-up without enabling optional toggles", async () => {
|
||||
// Click the "New follow-up" button in the empty state
|
||||
await page.getByRole("button", { name: "New follow-up" }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Test Follow-Up");
|
||||
|
||||
// Leave trigger as default ("Respondent completes survey")
|
||||
// Leave "Attach response data" toggle OFF (the key scenario for the bug)
|
||||
// Leave "Include variables" and "Include hidden fields" unchecked
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear — this was the bug: previously save failed silently
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("Verify follow-up appears in the list", async () => {
|
||||
// After creation, the modal closes and the follow-up should appear in the list
|
||||
await expect(page.getByText("Test Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Any response")).toBeVisible();
|
||||
await expect(page.getByText("Send email")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Edit the follow-up and verify it saves", async () => {
|
||||
// Click on the follow-up to edit it
|
||||
await page.getByText("Test Follow-Up").click();
|
||||
|
||||
// Verify the edit modal opens
|
||||
await expect(page.getByText("Edit this follow-up")).toBeVisible();
|
||||
|
||||
// Change the name
|
||||
const nameInput = page.getByPlaceholder("Name your follow-up");
|
||||
await nameInput.clear();
|
||||
await nameInput.fill("Updated Follow-Up");
|
||||
|
||||
// Save the edit
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify the updated name appears in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("Create a second follow-up with optional toggles enabled", async () => {
|
||||
// Click "+ New follow-up" button (now in the non-empty state header)
|
||||
await page.getByRole("button", { name: /New follow-up/ }).click();
|
||||
|
||||
// Verify the modal is open
|
||||
await expect(page.getByText("Create a new follow-up")).toBeVisible();
|
||||
|
||||
// Fill in the follow-up name
|
||||
await page.getByPlaceholder("Name your follow-up").fill("Follow-Up With Data");
|
||||
|
||||
// Enable "Attach response data" toggle
|
||||
await page.locator("#attachResponseData").click();
|
||||
|
||||
// Check both optional checkboxes
|
||||
await page.locator("#includeVariables").click();
|
||||
await page.locator("#includeHiddenFields").click();
|
||||
|
||||
// Click Save
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
|
||||
// The success toast should appear
|
||||
const successToast = await page.waitForSelector(".formbricks__toast__success", { timeout: 5000 });
|
||||
expect(successToast).toBeTruthy();
|
||||
|
||||
// Verify both follow-ups appear in the list
|
||||
await expect(page.getByText("Updated Follow-Up")).toBeVisible();
|
||||
await expect(page.getByText("Follow-Up With Data")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -115,7 +115,7 @@ export const setup = async (
|
||||
|
||||
const expiresAt = existingConfig.status.expiresAt;
|
||||
|
||||
if (expiresAt && isNowExpired(new Date(expiresAt))) {
|
||||
if (expiresAt && !isNowExpired(new Date(expiresAt))) {
|
||||
console.error("🧱 Formbricks - Error state is not expired, skipping initialization");
|
||||
return okVoid();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
import { setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
|
||||
import { filterSurveys, getIsDebug, isNowExpired } from "@/lib/common/utils";
|
||||
import type * as Utils from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
@@ -56,6 +56,7 @@ vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
...originalModule,
|
||||
filterSurveys: vi.fn(),
|
||||
isNowExpired: vi.fn(),
|
||||
getIsDebug: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -86,6 +87,7 @@ describe("setup.ts", () => {
|
||||
|
||||
getInstanceConfigMock = vi.spyOn(Config, "getInstance");
|
||||
getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -117,7 +119,8 @@ describe("setup.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips setup if existing config is in error state and not expired", async () => {
|
||||
test("skips setup if existing config is in error state and not expired (debug mode)", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(true);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
@@ -131,7 +134,7 @@ describe("setup.ts", () => {
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(false); // Not expired
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
expect(result.ok).toBe(true);
|
||||
@@ -140,6 +143,59 @@ describe("setup.ts", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("skips initialization if error state is active (not expired)", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: {},
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
|
||||
}),
|
||||
resetConfig: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(false); // Time is NOT up
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
// Should NOT fetch environment or user state
|
||||
expect(fetchEnvironmentState).not.toHaveBeenCalled();
|
||||
expect(mockConfig.resetConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("continues initialization if error state is expired", async () => {
|
||||
(getIsDebug as unknown as Mock).mockReturnValue(false);
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
environmentId: "env_123",
|
||||
appUrl: "https://my.url",
|
||||
environment: { data: { surveys: [] }, expiresAt: new Date() },
|
||||
user: { data: {}, expiresAt: null },
|
||||
status: { value: "error", expiresAt: new Date(Date.now() - 10000) },
|
||||
}),
|
||||
update: vi.fn(),
|
||||
};
|
||||
|
||||
getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config);
|
||||
(isNowExpired as unknown as Mock).mockReturnValue(true); // Time IS up
|
||||
|
||||
// Mock successful fetch to allow setup to proceed
|
||||
(fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
data: { data: { surveys: [] }, expiresAt: new Date() },
|
||||
});
|
||||
(filterSurveys as unknown as Mock).mockReturnValue([]);
|
||||
|
||||
const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(fetchEnvironmentState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
|
||||
const mockConfig = {
|
||||
get: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -19,6 +19,8 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
*/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
// Keep dist when running watch so surveys (and others) can resolve types during parallel go
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: "src/index.ts",
|
||||
formats: ["es"],
|
||||
|
||||
@@ -6,6 +6,36 @@ import { FILE_PICK_EVENT } from "@/lib/constants";
|
||||
import { getI18nLanguage } from "@/lib/i18n-utils";
|
||||
import { addCustomThemeToDom, addStylesToDom, setStyleNonce } from "@/lib/styles";
|
||||
|
||||
// Polyfill for webkit messageHandlers to prevent errors in browsers that don't fully support it
|
||||
// (e.g., Facebook's iOS in-app browser). This prevents TypeError when accessing unregistered handlers.
|
||||
if (typeof window !== "undefined") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- WebKit types are not standard
|
||||
const win = window as any;
|
||||
|
||||
if (win.webkit?.messageHandlers) {
|
||||
const originalMessageHandlers = win.webkit.messageHandlers;
|
||||
|
||||
// Create a Proxy that safely handles access to potentially undefined message handlers
|
||||
win.webkit.messageHandlers = new Proxy(originalMessageHandlers, {
|
||||
get(target, prop) {
|
||||
const handler = target[prop as keyof typeof target];
|
||||
|
||||
// If the handler doesn't exist, return a safe mock object with a no-op postMessage
|
||||
if (!handler) {
|
||||
return {
|
||||
postMessage: () => {
|
||||
// Silently ignore - the message handler is not registered in this environment
|
||||
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return handler;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const renderSurveyInline = (props: SurveyContainerProps) => {
|
||||
const inlineProps: SurveyContainerProps = {
|
||||
...props,
|
||||
|
||||
126
packages/surveys/src/lib/webkit-polyfill.test.ts
Normal file
126
packages/surveys/src/lib/webkit-polyfill.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("WebKit messageHandlers polyfill", () => {
|
||||
let originalWebkit: any;
|
||||
let consoleDebugSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save the original webkit object if it exists
|
||||
originalWebkit = (window as any).webkit;
|
||||
consoleDebugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore the original webkit object
|
||||
if (originalWebkit) {
|
||||
(window as any).webkit = originalWebkit;
|
||||
} else {
|
||||
delete (window as any).webkit;
|
||||
}
|
||||
consoleDebugSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not throw when accessing undefined messageHandlers", () => {
|
||||
// Setup: Create a webkit object with messageHandlers but no specific handlers
|
||||
(window as any).webkit = {
|
||||
messageHandlers: {},
|
||||
};
|
||||
|
||||
// Apply the polyfill logic
|
||||
const originalMessageHandlers = (window as any).webkit.messageHandlers;
|
||||
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
|
||||
get(target, prop) {
|
||||
const handler = target[prop as keyof typeof target];
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
postMessage: () => {
|
||||
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return handler;
|
||||
},
|
||||
});
|
||||
|
||||
// Test: Accessing an undefined handler should not throw
|
||||
expect(() => {
|
||||
(window as any).webkit.messageHandlers.undefinedHandler.postMessage("test");
|
||||
}).not.toThrow();
|
||||
|
||||
// Verify console.debug was called
|
||||
expect(consoleDebugSpy).toHaveBeenCalledWith(
|
||||
'WebKit message handler "undefinedHandler" is not available in this environment'
|
||||
);
|
||||
});
|
||||
|
||||
it("should still work with existing handlers", () => {
|
||||
// Setup: Create a webkit object with a real handler
|
||||
const mockPostMessage = vi.fn();
|
||||
(window as any).webkit = {
|
||||
messageHandlers: {
|
||||
existingHandler: {
|
||||
postMessage: mockPostMessage,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Apply the polyfill logic
|
||||
const originalMessageHandlers = (window as any).webkit.messageHandlers;
|
||||
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
|
||||
get(target, prop) {
|
||||
const handler = target[prop as keyof typeof target];
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
postMessage: () => {
|
||||
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return handler;
|
||||
},
|
||||
});
|
||||
|
||||
// Test: Existing handler should still work
|
||||
(window as any).webkit.messageHandlers.existingHandler.postMessage("test message");
|
||||
|
||||
expect(mockPostMessage).toHaveBeenCalledWith("test message");
|
||||
});
|
||||
|
||||
it("should handle multiple undefined handlers", () => {
|
||||
// Setup
|
||||
(window as any).webkit = {
|
||||
messageHandlers: {},
|
||||
};
|
||||
|
||||
// Apply the polyfill logic
|
||||
const originalMessageHandlers = (window as any).webkit.messageHandlers;
|
||||
(window as any).webkit.messageHandlers = new Proxy(originalMessageHandlers, {
|
||||
get(target, prop) {
|
||||
const handler = target[prop as keyof typeof target];
|
||||
|
||||
if (!handler) {
|
||||
return {
|
||||
postMessage: () => {
|
||||
console.debug(`WebKit message handler "${String(prop)}" is not available in this environment`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return handler;
|
||||
},
|
||||
});
|
||||
|
||||
// Test: Multiple undefined handlers should not throw
|
||||
expect(() => {
|
||||
(window as any).webkit.messageHandlers.handler1.postMessage("test1");
|
||||
(window as any).webkit.messageHandlers.handler2.postMessage("test2");
|
||||
(window as any).webkit.messageHandlers.handler3.postMessage("test3");
|
||||
}).not.toThrow();
|
||||
|
||||
expect(consoleDebugSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
@@ -7,10 +7,12 @@
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@formbricks/survey-ui": ["../survey-ui"]
|
||||
},
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "**/*.test.tsx"],
|
||||
"extends": "@formbricks/config-typescript/js-library.json",
|
||||
"include": ["src", "../types/surveys.d.ts"]
|
||||
}
|
||||
|
||||
@@ -88,16 +88,16 @@
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/surveys#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"dependsOn": ["^build", "@formbricks/survey-ui#build"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/surveys#build:dev": {
|
||||
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build"],
|
||||
"dependsOn": ["^build:dev", "@formbricks/i18n-utils#build", "@formbricks/survey-ui#build:dev"],
|
||||
"outputs": ["dist/**"]
|
||||
},
|
||||
"@formbricks/surveys#go": {
|
||||
"cache": false,
|
||||
"dependsOn": ["@formbricks/surveys#build"],
|
||||
"dependsOn": ["@formbricks/survey-ui#build", "@formbricks/surveys#build"],
|
||||
"persistent": true
|
||||
},
|
||||
"@formbricks/surveys#test": {
|
||||
|
||||
Reference in New Issue
Block a user