feat: reset survey (#6267)

This commit is contained in:
Dhruwang Jariwala
2025-07-22 17:34:26 +05:30
committed by GitHub
parent e83cfa85a4
commit 30fdcff737
15 changed files with 581 additions and 28 deletions

View File

@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);

View File

@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -178,6 +183,7 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -206,6 +212,8 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,6 +41,7 @@ const Page = async (props) => {
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -56,6 +58,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -18,6 +18,7 @@ import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -202,6 +203,61 @@ export const deleteResultShareUrlAction = authenticatedActionClient
)
);
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,
});

View File

@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
if (key === "environments.surveys.edit.caution_edit_duplicate") {
return "Duplicate & Edit";
}
if (key === "environments.surveys.summary.reset_survey") {
return "Reset survey";
}
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
return "Delete all existing responses and displays";
}
if (key === "environments.surveys.summary.reset_survey_warning") {
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
}
if (key === "environments.surveys.summary.survey_reset_successfully") {
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
}
return key;
},
}),
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
// Mock Next.js hooks
const mockPush = vi.fn();
const mockRefresh = vi.fn();
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
const mockSearchParams = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: mockPush,
refresh: mockRefresh,
}),
usePathname: () => mockPathname,
useSearchParams: () => mockSearchParams,
@@ -69,6 +83,10 @@ vi.mock("@/modules/survey/list/actions", () => ({
copySurveyToOtherEnvironmentAction: vi.fn(),
}));
vi.mock("../actions", () => ({
resetSurveyAction: vi.fn(),
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
@@ -147,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
),
}));
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
ConfirmationModal: ({
open,
setOpen,
title,
text,
buttonText,
onConfirm,
buttonVariant,
buttonLoading,
}: any) => (
<div
data-testid="confirmation-modal"
data-open={open}
data-loading={buttonLoading}
data-variant={buttonVariant}>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-text">{text}</div>
<button type="button" onClick={onConfirm} data-testid="confirm-button">
{buttonText}
</button>
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
Cancel
</button>
</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, className }: any) => (
<button type="button" data-testid="button" onClick={onClick} className={className}>
@@ -178,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
vi.mock("lucide-react", () => ({
BellRing: () => <svg data-testid="bell-ring-icon" />,
Eye: () => <svg data-testid="eye-icon" />,
ListRestart: () => <svg data-testid="list-restart-icon" />,
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
useEnvironment: vi.fn(() => ({
organizationId: "test-organization-id",
project: { id: "test-project-id" },
})),
}));
// Mock data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
@@ -270,6 +324,7 @@ const defaultProps = {
user: mockUser,
publicDomain: "https://example.com",
responseCount: 0,
displayCount: 0,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
@@ -286,19 +341,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders share survey button", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByText("Share survey")).toBeInTheDocument();
});
test("renders success message component", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("success-message")).toBeInTheDocument();
});
test("renders survey status dropdown when app setup is completed", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
});
@@ -310,7 +365,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("renders icon bar with correct actions", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
@@ -334,7 +389,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal when share button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -344,7 +399,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal when share param is true", () => {
mockSearchParams.set("share", "true");
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
@@ -352,7 +407,7 @@ describe("SurveyAnalysisCTA", () => {
test("navigates to edit when edit button is clicked and no responses", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
@@ -363,14 +418,15 @@ describe("SurveyAnalysisCTA", () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
await user.click(screen.getByTestId("icon-bar-action-1"));
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
});
test("navigates to notifications when bell icon is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByTestId("icon-bar-action-0"));
@@ -391,7 +447,7 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not show icon bar actions when read-only", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
const iconBar = screen.getByTestId("icon-bar");
expect(iconBar).toBeInTheDocument();
@@ -402,7 +458,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal close correctly", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Verify modal is open initially
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -429,13 +485,13 @@ describe("SurveyAnalysisCTA", () => {
test("does not show status dropdown when app setup is not completed", () => {
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
});
test("renders correctly with all props", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
expect(screen.getByText("Share survey")).toBeInTheDocument();
@@ -579,8 +635,8 @@ describe("SurveyAnalysisCTA", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Click edit button to open dialog
await user.click(screen.getByTestId("icon-bar-action-1"));
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
await user.click(screen.getByTestId("icon-bar-action-2"));
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
// Click primary button (duplicate & edit)
@@ -647,7 +703,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal with correct modal view when share button clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -669,24 +725,28 @@ describe("SurveyAnalysisCTA", () => {
});
test("does not render share modal when user is null", () => {
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
});
test("renders with different isFormbricksCloud values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("renders with different isContactsEnabled values", () => {
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
const { rerender } = render(
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
@@ -701,7 +761,7 @@ describe("SurveyAnalysisCTA", () => {
test("handles modal state changes correctly", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Open modal via share button
await user.click(screen.getByText("Share survey"));
@@ -714,7 +774,7 @@ describe("SurveyAnalysisCTA", () => {
test("opens share modal via share button", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
await user.click(screen.getByText("Share survey"));
@@ -726,7 +786,7 @@ describe("SurveyAnalysisCTA", () => {
test("closes share modal and updates modal state", async () => {
mockSearchParams.set("share", "true");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Modal should be open initially due to share param
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
@@ -738,19 +798,19 @@ describe("SurveyAnalysisCTA", () => {
});
test("handles empty segments array", () => {
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
});
test("handles zero response count", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
});
test("shows all icon actions for non-readonly app survey", () => {
render(<SurveyAnalysisCTA {...defaultProps} />);
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
// Should show bell (notifications) and edit actions
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
@@ -766,4 +826,236 @@ describe("SurveyAnalysisCTA", () => {
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
});
// Reset Survey Feature Tests
test("shows reset survey button when responses exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("shows reset survey button when displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeInTheDocument();
});
test("hides reset survey button when no responses or displays exist", () => {
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeUndefined();
});
test("hides reset survey button for read-only users", () => {
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
// For read-only users, there should be no icon bar actions
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
});
test("opens reset confirmation modal when reset button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
expect(screen.getByTestId("modal-text")).toHaveTextContent(
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
);
});
test("executes reset survey action when confirmed", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockResetSurveyAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
organizationId: "test-organization-id",
projectId: "test-project-id",
});
expect(toast.default.success).toHaveBeenCalledWith(
"Survey reset successfully! 5 responses and 3 displays were deleted."
);
});
test("handles reset survey action error", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: undefined,
serverError: "Reset failed",
validationErrors: undefined,
bindArgsValidationErrors: [],
});
const toast = await import("react-hot-toast");
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(toast.default.error).toHaveBeenCalledWith("Error message");
});
test("shows loading state during reset operation", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
// Mock a delayed response
mockResetSurveyAction.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
}),
100
)
)
);
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
// Check loading state
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
});
test("closes reset modal after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Confirm reset - wait for the action to complete
await user.click(screen.getByTestId("confirm-button"));
// Wait for the action to complete and the modal to close
await vi.waitFor(() => {
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
});
test("cancels reset operation when cancel button is clicked", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
// Cancel reset
await user.click(screen.getByTestId("cancel-button"));
// Modal should be closed
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
});
test("shows destructive button variant for reset confirmation", async () => {
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
});
test("refreshes page after successful reset", async () => {
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
mockResetSurveyAction.mockResolvedValue({
data: {
success: true,
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
},
});
const user = userEvent.setup();
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
// Open reset modal
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
expect(resetButton).toBeDefined();
await user.click(resetButton!);
// Confirm reset
await user.click(screen.getByTestId("confirm-button"));
expect(mockRefresh).toHaveBeenCalled();
});
});

View File

@@ -1,5 +1,6 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
@@ -9,9 +10,10 @@ import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
@@ -19,6 +21,7 @@ import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -27,6 +30,7 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
@@ -44,6 +48,7 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
@@ -57,7 +62,10 @@ export const SurveyAnalysisCTA = ({
start: searchParams.get("share") === "true",
share: false,
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -118,6 +126,29 @@ export const SurveyAnalysisCTA = ({
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const handleResetSurvey = async () => {
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
toast.success(
t("environments.surveys.summary.survey_reset_successfully", {
responseCount: result.data.deletedResponsesCount,
displayCount: result.data.deletedDisplaysCount,
})
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsResetting(false);
setIsResetModalOpen(false);
};
const iconActions = [
{
icon: BellRing,
@@ -134,6 +165,12 @@ export const SurveyAnalysisCTA = ({
},
isVisible: survey.type === "link",
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
@@ -202,6 +239,17 @@ export const SurveyAnalysisCTA = ({
secondaryButtonText={t("common.edit")}
/>
)}
<ConfirmationModal
open={isResetModalOpen}
setOpen={setIsResetModalOpen}
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
text={t("environments.surveys.summary.reset_survey_warning")}
buttonText={t("environments.surveys.summary.reset_survey")}
onConfirm={handleResetSurvey}
buttonVariant="destructive"
buttonLoading={isResetting}
/>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
response: {
deleteMany: vi.fn(),
},
display: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
},
}));
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
});
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
describe("Happy Path", () => {
test("Deletes all responses and displays for a survey", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return the results directly
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result).toEqual({
deletedResponsesCount: 5,
deletedDisplaysCount: 3,
});
});
test("Handles case with no responses or displays to delete", async () => {
const { prisma } = await import("@formbricks/database");
// Mock $transaction to return zero counts
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
expect(result).toEqual({
deletedResponsesCount: 0,
deletedDisplaysCount: 0,
});
});
});
describe("Sad Path", () => {
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const { prisma } = await import("@formbricks/database");
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
});
});
});

View File

@@ -0,0 +1,36 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
export const deleteResponsesAndDisplaysForSurvey = async (
surveyId: string
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
try {
// Delete all responses for this survey
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
prisma.response.deleteMany({
where: {
surveyId: surveyId,
},
}),
prisma.display.deleteMany({
where: {
surveyId: surveyId,
},
}),
]);
return {
deletedResponsesCount: deletedResponsesCount.count,
deletedDisplaysCount: deletedDisplaysCount.count,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
user={user}
publicDomain={publicDomain}
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
"custom_range": "Benutzerdefinierter Bereich...",
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
"download_qr_code": "QR Code herunterladen",
"drop_offs": "Drop-Off Rate",
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
"reset_survey": "Umfrage zurücksetzen",
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
"results_are_public": "Ergebnisse sind öffentlich",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
"this_month": "Dieser Monat",

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
"copy_link_to_public_results": "Copy link to public results",
"custom_range": "Custom range...",
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
"download_qr_code": "Download QR code",
"drop_offs": "Drop-Offs",
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "QR code download failed",
"qr_code_download_with_start_soon": "QR code download will start soon",
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
"reset_survey": "Reset survey",
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
"results_are_public": "Results are public",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "Show all responses where...",
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
"survey_results_are_public": "Your survey results are public!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
"this_month": "This month",

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
"custom_range": "Plage personnalisée...",
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
"download_qr_code": "Télécharger code QR",
"drop_offs": "Dépôts",
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "Échec du téléchargement du code QR",
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
"reset_survey": "Réinitialiser l'enquête",
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
"results_are_public": "Les résultats sont publics.",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "Afficher toutes les réponses où...",
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
"survey_results_are_public": "Les résultats de votre enquête sont publics !",
"survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.",
"this_month": "Ce mois-ci",

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
"download_qr_code": "baixar código QR",
"drop_offs": "Pontos de Entrega",
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "falha no download do código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"reset_survey": "Redefinir pesquisa",
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
"results_are_public": "Os resultados são públicos",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "Mostre todas as respostas onde...",
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
"this_month": "Este mês",

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
"copy_link_to_public_results": "Copiar link para resultados públicos",
"custom_range": "Intervalo personalizado...",
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
"download_qr_code": "Transferir código QR",
"drop_offs": "Desistências",
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "Falha ao transferir o código QR",
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
"reset_survey": "Reiniciar inquérito",
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
"results_are_public": "Os resultados são públicos",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "Mostrar todas as respostas onde...",
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
"survey_results_are_public": "Os resultados do seu inquérito são públicos!",
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.",
"this_month": "Este mês",

View File

@@ -1779,6 +1779,7 @@
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
"copy_link_to_public_results": "複製公開結果的連結",
"custom_range": "自訂範圍...",
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
"download_qr_code": "下載 QR code",
"drop_offs": "放棄",
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
@@ -1843,6 +1844,8 @@
"qr_code_download_failed": "QR code 下載失敗",
"qr_code_download_with_start_soon": "QR code 下載即將開始",
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
"reset_survey": "重設問卷",
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
"results_are_public": "結果是公開的",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
@@ -1853,6 +1856,7 @@
"show_all_responses_where": "顯示所有回應,其中...",
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
"survey_results_are_public": "您的問卷結果是公開的!",
"survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。",
"this_month": "本月",