fix: Editing active surveys (#5015)

Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
This commit is contained in:
Jakob Schott
2025-04-28 16:50:25 +02:00
committed by GitHub
parent b0aa08fe4e
commit a9eedd3c7a
24 changed files with 1045 additions and 130 deletions
@@ -57,6 +57,7 @@ const Page = async (props) => {
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
@@ -3,8 +3,11 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
@@ -22,6 +25,7 @@ interface SurveyAnalysisCTAProps {
isReadOnly: boolean;
user: TUser;
surveyDomain: string;
responseCount: number;
}
interface ModalState {
@@ -37,11 +41,13 @@ export const SurveyAnalysisCTA = ({
isReadOnly,
user,
surveyDomain,
responseCount,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
share: searchParams.get("share") === "true",
@@ -89,6 +95,24 @@ export const SurveyAnalysisCTA = ({
setModalState((prev) => ({ ...prev, dropdown: false }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
setLoading(true);
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
environmentId: environment.id,
surveyId: surveyId,
targetEnvironmentId: environment.id,
});
if (duplicatedSurveyResponse?.data) {
toast.success(t("environments.surveys.survey_duplicated_successfully"));
router.push(`/environments/${environment.id}/surveys/${duplicatedSurveyResponse.data.id}/edit`);
} else {
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
toast.error(errorMessage);
}
setIsCautionDialogOpen(false);
setLoading(false);
};
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
@@ -107,6 +131,8 @@ export const SurveyAnalysisCTA = ({
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
];
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const iconActions = [
{
icon: Eye,
@@ -144,7 +170,11 @@ export const SurveyAnalysisCTA = ({
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
onClick: () => router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`),
onClick: () => {
responseCount && responseCount > 0
? setIsCautionDialogOpen(true)
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
},
isVisible: !isReadOnly,
},
];
@@ -182,6 +212,20 @@ export const SurveyAnalysisCTA = ({
<SuccessMessage environment={environment} survey={survey} />
</>
)}
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={() => duplicateSurveyAndRoute(survey.id)}
primaryButtonText={t("environments.surveys.edit.caution_edit_duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
/>
)}
</div>
);
};
@@ -1,7 +1,7 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -49,10 +49,12 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
}));
const mockSearchParams = new URLSearchParams();
const mockPush = vi.fn();
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
useSearchParams: () => mockSearchParams, // Reuse the same object
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
usePathname: () => "/current",
}));
@@ -61,13 +63,27 @@ vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
}));
// Mock the copy survey action
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
}));
// Mock getFormattedErrorMessage function
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
}));
vi.spyOn(toast, "success");
vi.spyOn(toast, "error");
// Set up a fake clipboard
const writeTextMock = vi.fn(() => Promise.resolve());
Object.assign(navigator, {
clipboard: { writeText: writeTextMock },
// Mock clipboard API
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
// Define it at the global level
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
});
const dummySurvey = {
@@ -93,6 +109,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
@@ -117,6 +134,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
@@ -130,3 +148,225 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
});
});
});
// New tests for squarePenIcon and edit functionality
describe("SurveyAnalysisCTA - Edit functionality", () => {
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
cleanup();
});
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Check if dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
});
test("navigates directly to edit page when response count = 0", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={0}
/>
);
// Find the edit button
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Should navigate directly to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("doesn't show edit button when isReadOnly is true", () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={true}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Try to find the edit button (it shouldn't exist)
const editButton = screen.queryByRole("button", { name: "common.edit" });
expect(editButton).not.toBeInTheDocument();
});
});
// Updated test description to mention EditPublicSurveyAlertDialog
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
});
test("duplicates survey successfully and navigates to edit page", async () => {
// Mock the API response
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
data: { id: "duplicated-survey-456" },
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Find and click the edit button to show dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Find and click the duplicate button in dialog
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify the API was called with correct parameters
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
environmentId: dummyEnvironment.id,
surveyId: dummySurvey.id,
targetEnvironmentId: dummyEnvironment.id,
});
// Verify success toast was shown
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
// Verify navigation to edit page
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
);
});
test("shows error toast when duplication fails with error object", async () => {
// Mock API failure with error object
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
error: "Test error message",
});
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Verify error toast
expect(toast.error).toHaveBeenCalledWith("Test error message");
});
test("navigates to edit page when cancel button is clicked in dialog", async () => {
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click edit (cancel) button
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButtonInDialog);
// Verify navigation
expect(mockPush).toHaveBeenCalledWith(
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
);
});
test("shows loading state when duplicating survey", async () => {
// Create a promise that we can resolve manually
let resolvePromise: (value: any) => void;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
render(
<SurveyAnalysisCTA
survey={dummySurvey}
environment={dummyEnvironment}
isReadOnly={false}
surveyDomain={surveyDomain}
user={dummyUser}
responseCount={5}
/>
);
// Open dialog
const editButton = screen.getByRole("button", { name: "common.edit" });
await fireEvent.click(editButton);
// Click duplicate
const duplicateButton = screen.getByRole("button", {
name: "environments.surveys.edit.caution_edit_duplicate",
});
await fireEvent.click(duplicateButton);
// Button should now be in loading state
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
// Resolve the promise
resolvePromise!({
data: { id: "duplicated-survey-456" },
});
// Wait for the promise to resolve
await waitFor(() => {
expect(mockPush).toHaveBeenCalled();
});
});
});
@@ -68,6 +68,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
isReadOnly={isReadOnly}
user={user}
surveyDomain={surveyDomain}
responseCount={totalResponseCount}
/>
}>
{isAIEnabled && shouldGenerateInsights && (
@@ -204,7 +204,7 @@ export const LoginForm = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full pr-8 rounded-md border-slate-300 shadow-sm sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 pr-8 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
@@ -0,0 +1,154 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EditPublicSurveyAlertDialog } from "./index";
// Mock translation to return keys as text
vi.mock("@tolgee/react", () => ({ useTranslate: () => ({ t: (key: string) => key }) }));
describe("EditPublicSurveyAlertDialog", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders with all expected content", () => {
const setOpen = vi.fn();
render(<EditPublicSurveyAlertDialog open={true} setOpen={setOpen} />);
// Title
expect(screen.getByText("environments.surveys.edit.caution_edit_published_survey")).toBeInTheDocument();
// Paragraphs
expect(screen.getByText("environments.surveys.edit.caution_recommendation")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.edit.caution_explanation_intro")).toBeInTheDocument();
// List items
expect(
screen.getByText("environments.surveys.edit.caution_explanation_responses_are_safe")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.caution_explanation_new_responses_separated")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.caution_explanation_only_new_responses_in_summary")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.edit.caution_explanation_all_data_as_download")
).toBeInTheDocument();
});
test("renders default close button and calls setOpen when clicked", () => {
const setOpen = vi.fn();
render(<EditPublicSurveyAlertDialog open={true} setOpen={setOpen} />);
// Find the close button in the UI
const closeButton = screen.getByRole("button", { name: "common.close" });
expect(closeButton).toBeInTheDocument();
fireEvent.click(closeButton);
expect(setOpen).toHaveBeenCalledWith(false);
});
test("renders primary button and calls action when clicked", async () => {
const setOpen = vi.fn();
const primaryAction = vi.fn().mockResolvedValue(undefined);
render(
<EditPublicSurveyAlertDialog
open={true}
setOpen={setOpen}
primaryButtonAction={primaryAction}
primaryButtonText="Primary Text"
/>
);
const primaryButton = screen.getByRole("button", { name: "Primary Text" });
expect(primaryButton).toBeInTheDocument();
fireEvent.click(primaryButton);
await waitFor(() => {
expect(primaryAction).toHaveBeenCalledTimes(1);
});
});
test("renders secondary button and calls action when clicked", () => {
const setOpen = vi.fn();
const secondaryAction = vi.fn();
render(
<EditPublicSurveyAlertDialog
open={true}
setOpen={setOpen}
secondaryButtonAction={secondaryAction}
secondaryButtonText="Secondary Text"
/>
);
const secondaryButton = screen.getByRole("button", { name: "Secondary Text" });
expect(secondaryButton).toBeInTheDocument();
fireEvent.click(secondaryButton);
expect(secondaryAction).toHaveBeenCalledTimes(1);
expect(setOpen).not.toHaveBeenCalled();
});
test("renders both buttons when both actions are provided", async () => {
const setOpen = vi.fn();
const primaryAction = vi.fn().mockResolvedValue(undefined);
const secondaryAction = vi.fn();
render(
<EditPublicSurveyAlertDialog
open={true}
setOpen={setOpen}
primaryButtonAction={primaryAction}
primaryButtonText="Primary Text"
secondaryButtonAction={secondaryAction}
secondaryButtonText="Secondary Text"
/>
);
// Modal has a close button by default plus our two action buttons
const allButtons = screen.getAllByRole("button");
// Verify our two action buttons by their text content
const actionButtons = allButtons.filter(
(button) => button.textContent === "Secondary Text" || button.textContent === "Primary Text"
);
expect(actionButtons).toHaveLength(2);
expect(actionButtons[0]).toHaveTextContent("Secondary Text");
expect(actionButtons[1]).toHaveTextContent("Primary Text");
fireEvent.click(actionButtons[0]);
expect(secondaryAction).toHaveBeenCalledTimes(1);
fireEvent.click(actionButtons[1]);
await waitFor(() => {
expect(primaryAction).toHaveBeenCalledTimes(1);
});
});
test("shows loading state in primary button when isLoading is true", () => {
const setOpen = vi.fn();
const primaryAction = vi.fn().mockResolvedValue(undefined);
render(
<EditPublicSurveyAlertDialog
open={true}
setOpen={setOpen}
isLoading={true}
primaryButtonAction={primaryAction}
primaryButtonText="Primary Text"
/>
);
const primaryButton = screen.getByRole("button", { name: "Primary Text" });
// Check if button has loading class or attribute
expect(
primaryButton.classList.contains("loading") ||
primaryButton.innerHTML.includes("loader") ||
primaryButton.getAttribute("aria-busy") === "true"
).toBeTruthy();
});
});
@@ -0,0 +1,74 @@
import { Button } from "@/modules/ui/components/button";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
interface EditPublicSurveyAlertDialogProps {
open: boolean;
setOpen: (open: boolean) => void;
isLoading?: boolean;
primaryButtonAction?: () => Promise<void>;
secondaryButtonAction?: () => void;
primaryButtonText?: string;
secondaryButtonText?: string;
}
export const EditPublicSurveyAlertDialog = ({
open,
setOpen,
isLoading = false,
primaryButtonAction,
secondaryButtonAction,
primaryButtonText,
secondaryButtonText,
}: EditPublicSurveyAlertDialogProps) => {
const { t } = useTranslate();
const actions = [] as Array<{
label?: string;
onClick: () => void | Promise<void>;
disabled?: boolean;
loading?: boolean;
variant: React.ComponentProps<typeof Button>["variant"];
}>;
if (secondaryButtonAction) {
actions.push({
label: secondaryButtonText,
onClick: secondaryButtonAction,
disabled: isLoading,
variant: "outline",
});
}
if (primaryButtonAction) {
actions.push({
label: primaryButtonText,
onClick: primaryButtonAction,
loading: isLoading,
variant: "default",
});
}
if (actions.length === 0) {
actions.push({
label: secondaryButtonText ?? t("common.close"),
onClick: () => setOpen(false),
variant: "default",
});
}
return (
<Modal open={open} setOpen={setOpen} title={t("environments.surveys.edit.caution_edit_published_survey")}>
<p>{t("environments.surveys.edit.caution_recommendation")}</p>
<p className="mt-3">{t("environments.surveys.edit.caution_explanation_intro")}</p>
<ul className="mt-3 list-disc space-y-0.5 pl-5">
<li>{t("environments.surveys.edit.caution_explanation_responses_are_safe")}</li>
<li>{t("environments.surveys.edit.caution_explanation_new_responses_separated")}</li>
<li>{t("environments.surveys.edit.caution_explanation_only_new_responses_in_summary")}</li>
<li>{t("environments.surveys.edit.caution_explanation_all_data_as_download")}</li>
</ul>
<div className="my-4 space-x-2 text-right">
{actions.map(({ label, onClick, loading, variant, disabled }) => (
<Button key={label} variant={variant} onClick={onClick} loading={loading} disabled={disabled}>
{label}
</Button>
))}
</div>
</Modal>
);
};
@@ -21,6 +21,7 @@ import { RankingQuestionForm } from "@/modules/survey/editor/components/ranking-
import { RatingQuestionForm } from "@/modules/survey/editor/components/rating-question-form";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIconMap, getTSurveyQuestionTypeEnumName } from "@/modules/survey/lib/questions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
import { useSortable } from "@dnd-kit/sortable";
@@ -59,6 +60,8 @@ interface QuestionCardProps {
isFormbricksCloud: boolean;
isCxMode: boolean;
locale: TUserLocale;
responseCount: number;
onAlertTrigger: () => void;
}
export const QuestionCard = ({
@@ -80,6 +83,8 @@ export const QuestionCard = ({
isFormbricksCloud,
isCxMode,
locale,
responseCount,
onAlertTrigger,
}: QuestionCardProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: question.id,
@@ -257,6 +262,21 @@ export const QuestionCard = ({
</div>
</Collapsible.CollapsibleTrigger>
<Collapsible.CollapsibleContent className={`flex flex-col px-4 ${open && "pb-4"}`}>
{responseCount > 0 &&
[
TSurveyQuestionTypeEnum.MultipleChoiceSingle,
TSurveyQuestionTypeEnum.MultipleChoiceMulti,
TSurveyQuestionTypeEnum.PictureSelection,
TSurveyQuestionTypeEnum.Rating,
TSurveyQuestionTypeEnum.NPS,
TSurveyQuestionTypeEnum.Ranking,
TSurveyQuestionTypeEnum.Matrix,
].includes(question.type) ? (
<Alert variant="warning" size="small" className="w-fill">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => onAlertTrigger()}>{t("common.learn_more")}</AlertButton>
</Alert>
) : null}
{question.type === TSurveyQuestionTypeEnum.OpenText ? (
<OpenQuestionForm
localSurvey={localSurvey}
@@ -21,6 +21,8 @@ interface QuestionsDraggableProps {
isFormbricksCloud: boolean;
isCxMode: boolean;
locale: TUserLocale;
responseCount: number;
onAlertTrigger: () => void;
}
export const QuestionsDroppable = ({
@@ -39,6 +41,8 @@ export const QuestionsDroppable = ({
isFormbricksCloud,
isCxMode,
locale,
responseCount,
onAlertTrigger,
}: QuestionsDraggableProps) => {
const [parent] = useAutoAnimate();
@@ -66,6 +70,8 @@ export const QuestionsDroppable = ({
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
locale={locale}
responseCount={responseCount}
onAlertTrigger={onAlertTrigger}
/>
))}
</SortableContext>
@@ -63,6 +63,8 @@ interface QuestionsViewProps {
plan: TOrganizationBillingPlan;
isCxMode: boolean;
locale: TUserLocale;
responseCount: number;
setIsCautionDialogOpen: (open: boolean) => void;
}
export const QuestionsView = ({
@@ -81,6 +83,8 @@ export const QuestionsView = ({
plan,
isCxMode,
locale,
responseCount,
setIsCautionDialogOpen,
}: QuestionsViewProps) => {
const { t } = useTranslate();
const internalQuestionIdMap = useMemo(() => {
@@ -460,6 +464,8 @@ export const QuestionsView = ({
isFormbricksCloud={isFormbricksCloud}
isCxMode={isCxMode}
locale={locale}
responseCount={responseCount}
onAlertTrigger={() => setIsCautionDialogOpen(true)}
/>
</DndContext>
@@ -4,6 +4,7 @@ import { extractLanguageCodes, getEnabledLanguages } from "@/lib/i18n/utils";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { useDocumentVisibility } from "@/lib/useDocumentVisibility";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { LoadingSkeleton } from "@/modules/survey/editor/components/loading-skeleton";
import { QuestionsView } from "@/modules/survey/editor/components/questions-view";
import { SettingsView } from "@/modules/survey/editor/components/settings-view";
@@ -89,6 +90,8 @@ export const SurveyEditor = ({
}
}, [localProject.id]);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
useDocumentVisibility(fetchLatestProject);
useEffect(() => {
@@ -160,6 +163,7 @@ export const SurveyEditor = ({
setSelectedLanguageCode={setSelectedLanguageCode}
isCxMode={isCxMode}
locale={locale}
setIsCautionDialogOpen={setIsCautionDialogOpen}
/>
<div className="relative z-0 flex flex-1 overflow-hidden">
<main
@@ -190,6 +194,8 @@ export const SurveyEditor = ({
plan={plan}
isCxMode={isCxMode}
locale={locale}
responseCount={responseCount}
setIsCautionDialogOpen={setIsCautionDialogOpen}
/>
)}
@@ -250,6 +256,7 @@ export const SurveyEditor = ({
/>
</aside>
</div>
<EditPublicSurveyAlertDialog open={isCautionDialogOpen} setOpen={setIsCautionDialogOpen} />
</div>
);
};
@@ -2,14 +2,14 @@
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSegmentAction } from "@/modules/ee/contacts/segments/actions";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { Project } from "@prisma/client";
import { useTranslate } from "@tolgee/react";
import { isEqual } from "lodash";
import { AlertTriangleIcon, ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { ArrowLeftIcon, SettingsIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
@@ -40,6 +40,7 @@ interface SurveyMenuBarProps {
setSelectedLanguageCode: (selectedLanguage: string) => void;
isCxMode: boolean;
locale: string;
setIsCautionDialogOpen: (open: boolean) => void;
}
export const SurveyMenuBar = ({
@@ -55,6 +56,7 @@ export const SurveyMenuBar = ({
selectedLanguageCode,
isCxMode,
locale,
setIsCautionDialogOpen,
}: SurveyMenuBarProps) => {
const { t } = useTranslate();
const router = useRouter();
@@ -63,7 +65,6 @@ export const SurveyMenuBar = ({
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
const [isSurveySaving, setIsSurveySaving] = useState(false);
const cautionText = t("environments.surveys.edit.caution_text");
useEffect(() => {
if (audiencePrompt && activeId === "settings") {
@@ -303,111 +304,100 @@ export const SurveyMenuBar = ({
};
return (
<>
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
{!isCxMode && (
<Button
size="sm"
variant="secondary"
className="h-full"
onClick={() => {
handleBack();
}}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
)}
<p className="hidden pl-4 font-semibold md:block">{project.name} / </p>
<Input
defaultValue={localSurvey.name}
onChange={(e) => {
const updatedSurvey = { ...localSurvey, name: e.target.value };
setLocalSurvey(updatedSurvey);
}}
className="h-8 w-72 border-white py-0 hover:border-slate-200"
/>
</div>
{responseCount > 0 && (
<div className="flex items-center rounded-lg border border-amber-200 bg-amber-100 p-1.5 text-amber-800 shadow-sm lg:mx-auto">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<AlertTriangleIcon className="h-5 w-5 text-amber-400" />
</TooltipTrigger>
<TooltipContent side={"top"} className="lg:hidden">
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400">{cautionText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="hidden pl-1.5 text-xs text-ellipsis whitespace-nowrap md:text-sm lg:block">
{cautionText}
</p>
</div>
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex h-full items-center space-x-2 whitespace-nowrap">
{!isCxMode && (
<Button
size="sm"
variant="secondary"
className="h-full"
onClick={() => {
handleBack();
}}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
)}
<div className="mt-3 flex sm:mt-0 sm:ml-4">
{!isCxMode && (
<Button
disabled={disableSave}
variant="secondary"
size="sm"
className="mr-3"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
type="submit">
{t("common.save")}
</Button>
)}
{localSurvey.status !== "draft" && (
<Button
disabled={disableSave}
className="mr-3"
size="sm"
loading={isSurveySaving}
onClick={() => handleSaveAndGoBack()}>
{t("environments.surveys.edit.save_and_close")}
</Button>
)}
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
<Button
size="sm"
onClick={() => {
setAudiencePrompt(false);
setActiveId("settings");
}}>
{t("environments.surveys.edit.continue_to_settings")}
<SettingsIcon />
</Button>
)}
{/* Always display Publish button for link surveys for better CR */}
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
<Button
size="sm"
disabled={isSurveySaving || containsEmptyTriggers}
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
{isCxMode
? t("environments.surveys.edit.save_and_close")
: t("environments.surveys.edit.publish")}
</Button>
)}
</div>
<AlertDialog
headerText={t("environments.surveys.edit.confirm_survey_changes")}
open={isConfirmDialogOpen}
setOpen={setConfirmDialogOpen}
mainText={t("environments.surveys.edit.unsaved_changes_warning")}
confirmBtnLabel={t("common.save")}
declineBtnLabel={t("common.discard")}
declineBtnVariant="destructive"
onDecline={() => {
setConfirmDialogOpen(false);
router.back();
<p className="hidden pl-4 font-semibold md:block">{project.name} / </p>
<Input
defaultValue={localSurvey.name}
onChange={(e) => {
const updatedSurvey = { ...localSurvey, name: e.target.value };
setLocalSurvey(updatedSurvey);
}}
onConfirm={() => handleSaveAndGoBack()}
className="h-8 w-72 border-white py-0 hover:border-slate-200"
/>
</div>
</>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
{responseCount > 0 && (
<div>
<Alert variant="warning" size="small">
<AlertTitle>{t("environments.surveys.edit.caution_text")}</AlertTitle>
<AlertButton onClick={() => setIsCautionDialogOpen(true)}>{t("common.learn_more")}</AlertButton>
</Alert>
</div>
)}
{!isCxMode && (
<Button
disabled={disableSave}
variant="secondary"
size="sm"
loading={isSurveySaving}
onClick={() => handleSurveySave()}
type="submit">
{t("common.save")}
</Button>
)}
{localSurvey.status !== "draft" && (
<Button
disabled={disableSave}
className="mr-3"
size="sm"
loading={isSurveySaving}
onClick={() => handleSaveAndGoBack()}>
{t("environments.surveys.edit.save_and_close")}
</Button>
)}
{localSurvey.status === "draft" && audiencePrompt && !isLinkSurvey && (
<Button
size="sm"
onClick={() => {
setAudiencePrompt(false);
setActiveId("settings");
}}>
{t("environments.surveys.edit.continue_to_settings")}
<SettingsIcon />
</Button>
)}
{/* Always display Publish button for link surveys for better CR */}
{localSurvey.status === "draft" && (!audiencePrompt || isLinkSurvey) && (
<Button
size="sm"
disabled={isSurveySaving || containsEmptyTriggers}
loading={isSurveyPublishing}
onClick={handleSurveyPublish}>
{isCxMode
? t("environments.surveys.edit.save_and_close")
: t("environments.surveys.edit.publish")}
</Button>
)}
</div>
<AlertDialog
headerText={t("environments.surveys.edit.confirm_survey_changes")}
open={isConfirmDialogOpen}
setOpen={setConfirmDialogOpen}
mainText={t("environments.surveys.edit.unsaved_changes_warning")}
confirmBtnLabel={t("common.save")}
declineBtnLabel={t("common.discard")}
declineBtnVariant="destructive"
onDecline={() => {
setConfirmDialogOpen(false);
router.back();
}}
onConfirm={handleSaveAndGoBack}
/>
</div>
);
};
@@ -2,6 +2,7 @@
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
import {
copySurveyToOtherEnvironmentAction,
@@ -59,6 +60,8 @@ export const SurveyDropDownMenu = ({
const [loading, setLoading] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isCopyFormOpen, setIsCopyFormOpen] = useState(false);
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const router = useRouter();
const surveyLink = useMemo(() => surveyDomain + "/s/" + survey.id, [survey.id, surveyDomain]);
@@ -117,6 +120,12 @@ export const SurveyDropDownMenu = ({
setLoading(false);
};
const handleEditforActiveSurvey = (e) => {
e.preventDefault();
setIsDropDownOpen(false);
setIsCautionDialogOpen(true);
};
return (
<div
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
@@ -140,8 +149,9 @@ export const SurveyDropDownMenu = ({
<DropdownMenuItem>
<Link
className="flex w-full items-center"
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}>
<SquarePenIcon className="mr-2 h-4 w-4" />
href={`/environments/${environmentId}/surveys/${survey.id}/edit`}
onClick={survey.responseCount > 0 ? handleEditforActiveSurvey : undefined}>
<SquarePenIcon className="mr-2 size-4" />
{t("common.edit")}
</Link>
</DropdownMenuItem>
@@ -238,6 +248,23 @@ export const SurveyDropDownMenu = ({
/>
)}
{survey.responseCount > 0 && (
<EditPublicSurveyAlertDialog
open={isCautionDialogOpen}
setOpen={setIsCautionDialogOpen}
isLoading={loading}
primaryButtonAction={async () => {
await duplicateSurveyAndRefresh(survey.id);
setIsCautionDialogOpen(false);
}}
primaryButtonText={t("common.duplicate")}
secondaryButtonAction={() =>
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`)
}
secondaryButtonText={t("common.edit")}
/>
)}
{isCopyFormOpen && (
<CopySurveyModal open={isCopyFormOpen} setOpen={setIsCopyFormOpen} survey={survey} />
)}
@@ -1,9 +1,14 @@
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SurveyDropDownMenu } from "../survey-dropdown-menu";
// Mock translation
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Mock constants
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
@@ -39,13 +44,12 @@ vi.mock("@/modules/survey/lib/client-utils", () => ({
copySurveyLink: vi.fn((url: string, suId?: string) => (suId ? `${url}?suId=${suId}` : url)),
}));
const fakeSurvey = {
id: "testSurvey",
name: "Test Survey",
status: "inProgress",
type: "link",
creator: { name: "Test User" },
} as unknown as TSurvey;
vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(() => Promise.resolve({ data: { id: "duplicatedSurveyId" } })),
getSurveyAction: vi.fn(() =>
Promise.resolve({ data: { id: "duplicatedSurveyId", name: "Duplicated Survey" } })
),
}));
describe("SurveyDropDownMenu", () => {
afterEach(() => {
@@ -119,4 +123,127 @@ describe("SurveyDropDownMenu", () => {
expect(editItem).toBeInTheDocument();
expect(deleteItem).toBeInTheDocument();
});
const fakeSurvey = {
id: "testSurvey",
name: "Test Survey",
status: "inProgress",
type: "link",
responseCount: 5,
} as unknown as TSurvey;
test("handleEditforActiveSurvey opens EditPublicSurveyAlertDialog for active surveys", async () => {
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
surveyDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
expect(triggerElement).toBeInTheDocument();
await userEvent.click(triggerElement);
const editButton = screen.getByText("common.edit");
await userEvent.click(editButton);
expect(screen.getByText("environments.surveys.edit.caution_edit_published_survey")).toBeInTheDocument();
});
test("handleEditforActiveSurvey does not open caution dialog for surveys with 0 response count", async () => {
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, responseCount: 0 }}
surveyDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
expect(triggerElement).toBeInTheDocument();
await userEvent.click(triggerElement);
const editButton = screen.getByText("common.edit");
await userEvent.click(editButton);
expect(
screen.queryByText("environments.surveys.edit.caution_edit_published_survey")
).not.toBeInTheDocument();
});
test("<DropdownMenuItem> renders and triggers actions correctly", async () => {
const mockDuplicateSurvey = vi.fn();
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
surveyDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
expect(triggerElement).toBeInTheDocument();
await userEvent.click(triggerElement);
const duplicateButton = screen.getByText("common.duplicate");
expect(duplicateButton).toBeInTheDocument();
await userEvent.click(duplicateButton);
await waitFor(() => {
expect(mockDuplicateSurvey).toHaveBeenCalled();
});
});
test("<EditPublicSurveyAlertDialog> displays and handles actions correctly", async () => {
const mockDuplicateSurvey = vi.fn();
render(
<SurveyDropDownMenu
environmentId="env123"
survey={{ ...fakeSurvey, responseCount: 5 }}
surveyDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={mockDuplicateSurvey}
deleteSurvey={vi.fn()}
/>
);
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
expect(triggerElement).toBeInTheDocument();
await userEvent.click(triggerElement);
const editButton = screen.getByText("common.edit");
expect(editButton).toBeInTheDocument();
await userEvent.click(editButton);
// Test that the dialog is shown
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
expect(dialogTitle).toBeInTheDocument();
// Test that the dialog buttons work
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
expect(editButtonInDialog).toBeInTheDocument();
await userEvent.click(editButtonInDialog);
const duplicateButton = screen.getByRole("button", { name: "common.duplicate" });
expect(duplicateButton).toBeInTheDocument();
await userEvent.click(duplicateButton);
await waitFor(() => {
expect(mockDuplicateSurvey).toHaveBeenCalled();
});
});
});
@@ -11,8 +11,8 @@ interface AlertDialogProps {
mainText: string;
confirmBtnLabel: string;
declineBtnLabel?: string;
declineBtnVariant?: "destructive" | "ghost";
onDecline: () => void;
declineBtnVariant?: "destructive" | "ghost" | "outline";
onDecline?: () => void;
onConfirm?: () => void;
}
@@ -34,9 +34,11 @@ export const AlertDialog = ({
{mainText ?? t("common.are_you_sure_this_action_cannot_be_undone")}
</p>
<div className="space-x-2 text-right">
<Button variant={declineBtnVariant} onClick={onDecline}>
{declineBtnLabel || "Discard"}
</Button>
{declineBtnLabel && onDecline && (
<Button variant={declineBtnVariant} onClick={onDecline}>
{declineBtnLabel}
</Button>
)}
<Button
onClick={() => {
if (onConfirm) {
@@ -0,0 +1,142 @@
import { TolgeeNextProvider } from "@/tolgee/client";
import { Meta, StoryObj } from "@storybook/react";
import { AlertDialog } from "./index";
const meta: Meta<typeof AlertDialog> = {
title: "UI/AlertDialog",
component: AlertDialog,
tags: ["autodocs"],
argTypes: {
open: {
control: "boolean",
description: "Controls the open state of the dialog",
},
setOpen: {
description: "Function to set the open state",
},
headerText: {
control: "text",
description: "Heading text for the dialog",
},
mainText: {
control: "text",
description: "Main content text for the dialog",
},
confirmBtnLabel: {
control: "text",
description: "Label for the confirmation button",
},
declineBtnLabel: {
control: "text",
description: "Optional label for the decline button",
},
declineBtnVariant: {
control: "select",
options: ["destructive", "ghost"],
description: "Style variant for the decline button",
},
onConfirm: {
description: "Function called when confirm button is clicked",
},
onDecline: {
description: "Function called when decline button is clicked",
},
},
decorators: [
(Story) => (
<TolgeeNextProvider language="en" staticData={{}}>
<Story />
</TolgeeNextProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof AlertDialog>;
// Basic example
export const Default: Story = {
args: {
open: true,
setOpen: () => {},
headerText: "Confirm Action",
mainText: "Are you sure you want to proceed with this action?",
confirmBtnLabel: "Confirm",
declineBtnLabel: "Cancel",
onDecline: () => console.log("Declined"),
onConfirm: () => console.log("Confirmed"),
},
};
// Example with destructive action
export const Destructive: Story = {
args: {
open: true,
setOpen: () => {},
headerText: "Delete Item",
mainText: "This action cannot be undone. Are you sure you want to delete this item?",
confirmBtnLabel: "Delete",
declineBtnLabel: "Cancel",
declineBtnVariant: "ghost",
onDecline: () => console.log("Declined"),
onConfirm: () => console.log("Confirmed delete"),
},
parameters: {
docs: {
description: {
story: "Used for destructive actions that require user confirmation.",
},
},
},
};
// Example with warning
export const Warning: Story = {
args: {
open: true,
setOpen: () => {},
headerText: "Warning",
mainText: "You are about to make changes that will affect multiple records.",
confirmBtnLabel: "Proceed",
declineBtnLabel: "Go Back",
onDecline: () => console.log("Declined"),
onConfirm: () => console.log("Confirmed proceed"),
},
parameters: {
docs: {
description: {
story: "Used for warning users about consequential actions.",
},
},
},
};
// Example with success confirmation
export const SuccessConfirmation: Story = {
args: {
open: true,
setOpen: () => {},
headerText: "Success",
mainText: "Your changes have been saved successfully. Would you like to continue editing?",
confirmBtnLabel: "Continue Editing",
declineBtnLabel: "Close",
onDecline: () => console.log("Closed"),
onConfirm: () => console.log("Continue editing"),
},
};
// Example with destructive decline button
export const DestructiveDecline: Story = {
args: {
open: true,
setOpen: () => {},
headerText: "Discard Changes",
mainText: "You have unsaved changes. Are you sure you want to discard them?",
confirmBtnLabel: "Keep Editing",
declineBtnLabel: "Discard Changes",
declineBtnVariant: "destructive",
onDecline: () => console.log("Discarded changes"),
onConfirm: () => console.log("Keep editing"),
},
};
+2
View File
@@ -74,6 +74,7 @@ export default defineConfig({
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
"modules/ee/sso/lib/**/*.ts",
"app/lib/**/*.ts",
"app/api/(internal)/insights/lib/**/*.ts",
@@ -88,6 +89,7 @@ export default defineConfig({
"modules/survey/components/question-form-input/index.tsx",
"modules/survey/components/template-list/components/template-tags.tsx",
"modules/survey/lib/client-utils.ts",
"modules/survey/components/edit-public-survey-alert-dialog/index.tsx",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
"modules/survey/follow-ups/components/follow-up-item.tsx",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
"caution_edit_published_survey": "Eine veröffentlichte Umfrage bearbeiten?",
"caution_explanation_all_data_as_download": "Alle Daten, einschließlich früherer Antworten, stehen als Download zur Verfügung.",
"caution_explanation_intro": "Wir verstehen, dass du vielleicht noch Änderungen vornehmen möchtest. Hier erfährst du, was passiert, wenn du das tust:",
"caution_explanation_new_responses_separated": "Neue Antworten werden separat gesammelt.",
"caution_explanation_only_new_responses_in_summary": "Nur neue Antworten erscheinen in der Umfragezusammenfassung.",
"caution_explanation_responses_are_safe": "Vorhandene Antworten bleiben sicher.",
"caution_recommendation": "Das Bearbeiten deiner Umfrage kann zu Dateninkonsistenzen in der Umfragezusammenfassung führen. Wir empfehlen stattdessen, die Umfrage zu duplizieren.",
"caution_text": "Änderungen werden zu Inkonsistenzen führen",
"centered_modal_overlay_color": "Zentrierte modale Überlagerungsfarbe",
"change_anyway": "Trotzdem ändern",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "Um mit Umfragen in mobilen Apps zu beginnen, folge bitte der Schnellstartanleitung:",
"quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"response_inconsistencies_available_as_download": "Deine Daten sind sicher — frühere Antworten stehen zum Download bereit",
"response_inconsistencies_edited_while_public": "Bearbeitet, während öffentlich.",
"response_inconsistencies_explanation": "Die Online-Zusammenfassung zeigt Antworten, die nach der letzten Bearbeitung der Umfrage eingereicht wurden. Dies verhindert Dateninkonsistenzen in deiner Zusammenfassung. Wir empfehlen, veröffentlichte Umfragen zu duplizieren, anstatt sie zu bearbeiten.",
"response_inconsistencies_missing_responses_dont_worry": "Fehlende Antworten? Keine Sorge!",
"results_are_public": "Ergebnisse sind öffentlich",
"send_preview": "Vorschau senden",
"send_to_panel": "An das Panel senden",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
"caution_edit_published_survey": "Edit a published survey?",
"caution_explanation_all_data_as_download": "All data, including past responses are available as download.",
"caution_explanation_intro": "We understand you might still want to make changes. Heres what happens if you do: ",
"caution_explanation_new_responses_separated": "New responses are collected separately.",
"caution_explanation_only_new_responses_in_summary": "Only new responses appear in the survey summary.",
"caution_explanation_responses_are_safe": "Existing responses remain safe.",
"caution_recommendation": "Editing your survey may cause data inconsistencies in the survey summary. We recommend duplicating the survey instead.",
"caution_text": "Changes will lead to inconsistencies",
"centered_modal_overlay_color": "Centered modal overlay color",
"change_anyway": "Change anyway",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "To get started with surveys in mobile apps, please follow the Quickstart guide:",
"quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"response_inconsistencies_available_as_download": "Your data is safe — earlier responses are available for download. ",
"response_inconsistencies_edited_while_public": "Edited while public.",
"response_inconsistencies_explanation": "The online summary shows responses, that were submitted after the latest edit of the survey. This prevents data inconsistencies in your summary. We recommend duplicating published surveys, instead of editing them.",
"response_inconsistencies_missing_responses_dont_worry": "Missing responses? Dont worry!",
"results_are_public": "Results are public",
"send_preview": "Send preview",
"send_to_panel": "Send to panel",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte",
"casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier",
"caution_edit_published_survey": "Modifier un sondage publié ?",
"caution_explanation_all_data_as_download": "Toutes les données, y compris les réponses passées, sont disponibles en téléchargement.",
"caution_explanation_intro": "Nous comprenons que vous souhaitiez encore apporter des modifications. Voici ce qui se passe si vous le faites : ",
"caution_explanation_new_responses_separated": "Les nouvelles réponses sont collectées séparément.",
"caution_explanation_only_new_responses_in_summary": "Seules les nouvelles réponses apparaissent dans le résumé de l'enquête.",
"caution_explanation_responses_are_safe": "Les réponses existantes restent en sécurité.",
"caution_recommendation": "Modifier votre enquête peut entraîner des incohérences dans le résumé de l'enquête. Nous vous recommandons de dupliquer l'enquête à la place.",
"caution_text": "Les changements entraîneront des incohérences.",
"centered_modal_overlay_color": "Couleur de superposition modale centrée",
"change_anyway": "Changer de toute façon",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "Pour commencer avec les enquêtes dans les applications mobiles, veuillez suivre le guide de démarrage rapide :",
"quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"response_inconsistencies_available_as_download": "Vos données sont en sécurité — les réponses précédentes sont disponibles en téléchargement",
"response_inconsistencies_edited_while_public": "Modifié en public.",
"response_inconsistencies_explanation": "Le résumé en ligne affiche les réponses soumises après la dernière modification du sondage. Cela évite les incohérences de données dans votre résumé. Nous vous recommandons de dupliquer les sondages publiés, au lieu de les modifier.",
"response_inconsistencies_missing_responses_dont_worry": "Réponses manquantes ? Ne vous inquiétez pas !",
"results_are_public": "Les résultats sont publics.",
"send_preview": "Envoyer un aperçu",
"send_to_panel": "Envoyer au panneau",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar uma pesquisa publicada?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que você ainda pode querer fazer alterações. Aqui está o que acontece se você fizer:",
"caution_explanation_new_responses_separated": "Novas respostas são coletadas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo da pesquisa.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar sua pesquisa pode causar inconsistências de dados no resumo da pesquisa. Recomendamos duplicar a pesquisa em vez disso.",
"caution_text": "Mudanças vão levar a inconsistências",
"centered_modal_overlay_color": "cor de sobreposição modal centralizada",
"change_anyway": "Mudar mesmo assim",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "Para começar com pesquisas em aplicativos móveis, por favor, siga o guia de início rápido:",
"quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"response_inconsistencies_available_as_download": "Seus dados estão seguros — respostas anteriores estão disponíveis para download",
"response_inconsistencies_edited_while_public": "Editado enquanto público.",
"response_inconsistencies_explanation": "O resumo online mostra respostas que foram enviadas após a última edição da pesquisa. Isso evita inconsistências de dados no seu resumo. Recomendamos duplicar pesquisas publicadas, em vez de editá-las.",
"response_inconsistencies_missing_responses_dont_worry": "Respostas faltando? Não se preocupe!",
"results_are_public": "Os resultados são públicos",
"send_preview": "Enviar prévia",
"send_to_panel": "Enviar para o painel",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
"caution_edit_published_survey": "Editar um inquérito publicado?",
"caution_explanation_all_data_as_download": "Todos os dados, incluindo respostas anteriores, estão disponíveis para download.",
"caution_explanation_intro": "Entendemos que ainda pode querer fazer alterações. Eis o que acontece se o fizer:",
"caution_explanation_new_responses_separated": "As novas respostas são recolhidas separadamente.",
"caution_explanation_only_new_responses_in_summary": "Apenas novas respostas aparecem no resumo do inquérito.",
"caution_explanation_responses_are_safe": "As respostas existentes permanecem seguras.",
"caution_recommendation": "Editar o seu inquérito pode causar inconsistências de dados no resumo do inquérito. Recomendamos duplicar o inquérito em vez disso.",
"caution_text": "As alterações levarão a inconsistências",
"centered_modal_overlay_color": "Cor da sobreposição modal centralizada",
"change_anyway": "Alterar mesmo assim",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "Para começar com inquéritos em aplicações móveis, por favor, siga o guia de início rápido:",
"quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"response_inconsistencies_available_as_download": "Os seus dados estão seguros — as respostas anteriores estão disponíveis para download",
"response_inconsistencies_edited_while_public": "Editado enquanto público.",
"response_inconsistencies_explanation": "O resumo online mostra respostas, que foram submetidas após a última edição do inquérito. Isto previne inconsistências de dados no seu resumo. Recomendamos duplicar inquéritos publicados, em vez de os editar.",
"response_inconsistencies_missing_responses_dont_worry": "Respostas em falta? Não se preocupe!",
"results_are_public": "Os resultados são públicos",
"send_preview": "Enviar pré-visualização",
"send_to_panel": "Enviar para painel",
+12
View File
@@ -1332,6 +1332,14 @@
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
"caution_edit_published_survey": "編輯已發佈的調查?",
"caution_explanation_all_data_as_download": "所有數據,包括過去的回應,都可以下載。",
"caution_explanation_intro": "我們了解您可能仍然想要進行更改。如果您這樣做,將會發生以下情況:",
"caution_explanation_new_responses_separated": "新回應會分開收集。",
"caution_explanation_only_new_responses_in_summary": "只有新的回應會出現在調查摘要中。",
"caution_explanation_responses_are_safe": "現有回應仍然安全。",
"caution_recommendation": "編輯您的調查可能會導致調查摘要中的數據不一致。我們建議複製調查。",
"caution_text": "變更會導致不一致",
"centered_modal_overlay_color": "置中彈窗覆蓋顏色",
"change_anyway": "仍然變更",
@@ -1791,6 +1799,10 @@
"quickstart_mobile_apps_description": "要開始使用行動應用程式中的調查,請按照 Quickstart 指南:",
"quickstart_web_apps": "快速入門:Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"response_inconsistencies_available_as_download": "您的資料是安全的 — 先前的回應可供下載。",
"response_inconsistencies_edited_while_public": "公開時已編輯。",
"response_inconsistencies_explanation": "線上摘要顯示在調查最新編輯後提交的回應。這可以防止摘要中的數據不一致。我們建議複製已發布的調查,而不是編輯它們。",
"response_inconsistencies_missing_responses_dont_worry": "缺少回應?別擔心!",
"results_are_public": "結果是公開的",
"send_preview": "發送預覽",
"send_to_panel": "發送到小組",
+1 -1
View File
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/*.stories.*,**/stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,**/types/**,**/stories.*,**/mocks/**,**/__mocks__/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts,**/playwright/**,**/Dockerfile,**/*.config.cjs,**/*.css,**/templates.ts