mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-18 23:28:32 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6b612ba7 | |||
| 80d3d88532 | |||
| 778a02d3b3 |
@@ -0,0 +1,118 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromSurveyId, getWorkspaceIdFromSurveyId } from "@/lib/utils/helper";
|
||||
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { updateSurveyStatusAction } from "./actions";
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
authenticatedActionClient: {
|
||||
inputSchema: vi.fn(() => ({
|
||||
action: vi.fn((fn) => fn),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromSurveyId: vi.fn(),
|
||||
getOrganizationIdFromWorkspaceId: vi.fn(),
|
||||
getWorkspaceIdFromSurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/single-use-surveys", () => ({
|
||||
generateSurveySingleUseLinkParams: vi.fn(),
|
||||
generateSurveySingleUseLinkParamsList: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((_eventName, _targetType, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/survey", () => ({
|
||||
updateSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey", () => ({
|
||||
copySurveyToOtherWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const baseSurvey = {
|
||||
id: "survey_1",
|
||||
workspaceId: "workspace_1",
|
||||
status: "inProgress",
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
user: { id: "user_1" },
|
||||
auditLoggingCtx: {},
|
||||
};
|
||||
|
||||
describe("updateSurveyStatusAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getOrganizationIdFromSurveyId).mockResolvedValue("organization_1");
|
||||
vi.mocked(getWorkspaceIdFromSurveyId).mockResolvedValue("workspace_1");
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValue(undefined);
|
||||
vi.mocked(getSurvey).mockResolvedValue(baseSurvey as never);
|
||||
vi.mocked(updateSurvey).mockResolvedValue({ ...baseSurvey, status: "completed" } as never);
|
||||
});
|
||||
|
||||
test("updates a non-draft survey status with read-write access", async () => {
|
||||
const result = await updateSurveyStatusAction({
|
||||
ctx,
|
||||
parsedInput: { surveyId: "survey_1", status: "completed" },
|
||||
} as never);
|
||||
|
||||
expect(checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||
userId: "user_1",
|
||||
organizationId: "organization_1",
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId: "workspace_1",
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(updateSurvey).toHaveBeenCalledWith({ ...baseSurvey, status: "completed" });
|
||||
expect(ctx.auditLoggingCtx).toEqual({
|
||||
organizationId: "organization_1",
|
||||
surveyId: "survey_1",
|
||||
oldObject: baseSurvey,
|
||||
newObject: { ...baseSurvey, status: "completed" },
|
||||
});
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/workspaces/workspace_1/surveys");
|
||||
expect(revalidatePath).toHaveBeenCalledWith("/workspaces/workspace_1/surveys/survey_1");
|
||||
expect(result).toEqual({ ...baseSurvey, status: "completed" });
|
||||
});
|
||||
|
||||
test("rejects draft survey status changes from the list", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue({ ...baseSurvey, status: "draft" } as never);
|
||||
|
||||
await expect(
|
||||
updateSurveyStatusAction({
|
||||
ctx: { user: { id: "user_1" }, auditLoggingCtx: {} },
|
||||
parsedInput: { surveyId: "survey_1", status: "completed" },
|
||||
} as never)
|
||||
).rejects.toThrow(OperationNotAllowedError);
|
||||
expect(updateSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -14,6 +16,8 @@ import {
|
||||
generateSurveySingleUseLinkParamsList,
|
||||
} from "@/lib/utils/single-use-surveys";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { updateSurvey } from "@/modules/survey/editor/lib/survey";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { copySurveyToOtherWorkspace } from "@/modules/survey/list/lib/survey";
|
||||
|
||||
const ZCopySurveyToOtherWorkspaceAction = z.object({
|
||||
@@ -81,6 +85,55 @@ export const copySurveyToOtherWorkspaceAction = authenticatedActionClient
|
||||
})
|
||||
);
|
||||
|
||||
const ZUpdateSurveyStatusAction = z.object({
|
||||
surveyId: ZId,
|
||||
status: z.enum(["inProgress", "paused", "completed"]),
|
||||
});
|
||||
|
||||
export const updateSurveyStatusAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateSurveyStatusAction)
|
||||
.action(
|
||||
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
const workspaceId = await getWorkspaceIdFromSurveyId(parsedInput.surveyId);
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
workspaceId,
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
|
||||
if (survey.status === "draft") {
|
||||
throw new OperationNotAllowedError("Draft surveys must be published from the editor.");
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
const updatedSurvey = await updateSurvey({ ...survey, status: parsedInput.status });
|
||||
|
||||
ctx.auditLoggingCtx.newObject = updatedSurvey;
|
||||
|
||||
revalidatePath(`/workspaces/${updatedSurvey.workspaceId}/surveys`);
|
||||
revalidatePath(`/workspaces/${updatedSurvey.workspaceId}/surveys/${updatedSurvey.id}`);
|
||||
|
||||
return updatedSurvey;
|
||||
})
|
||||
);
|
||||
|
||||
const ZGenerateSingleUseIdAction = z
|
||||
.object({
|
||||
surveyId: z.cuid2(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { type ComponentProps, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
@@ -18,9 +18,17 @@ interface SurveyCardProps {
|
||||
publicDomain: string;
|
||||
isReadOnly: boolean;
|
||||
deleteSurvey: (surveyId: string) => Promise<void>;
|
||||
updateSurveyStatus: ComponentProps<typeof SurveyDropDownMenu>["updateSurveyStatus"];
|
||||
locale: TUserLocale;
|
||||
}
|
||||
export const SurveyCard = ({ survey, publicDomain, isReadOnly, deleteSurvey, locale }: SurveyCardProps) => {
|
||||
export const SurveyCard = ({
|
||||
survey,
|
||||
publicDomain,
|
||||
isReadOnly,
|
||||
deleteSurvey,
|
||||
updateSurveyStatus,
|
||||
locale,
|
||||
}: Readonly<SurveyCardProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspace } = useWorkspace();
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
@@ -104,6 +112,7 @@ export const SurveyCard = ({ survey, publicDomain, isReadOnly, deleteSurvey, loc
|
||||
disabled={isDraftAndReadOnly}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
deleteSurvey={deleteSurvey}
|
||||
updateSurveyStatus={updateSurveyStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,18 +9,35 @@ import { useTranslation } from "react-i18next";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
|
||||
import type { TSurveyListItem } from "@/modules/survey/list/types/survey-overview";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { SurveyStatusIndicator } from "@/modules/ui/components/survey-status-indicator";
|
||||
|
||||
type TSurveyStatusUpdate = Exclude<TSurveyListItem["status"], "draft">;
|
||||
type TUpdateSurveyStatusResponse = {
|
||||
data?: {
|
||||
status: TSurveyListItem["status"];
|
||||
publishOn: Date | null;
|
||||
};
|
||||
serverError?: string;
|
||||
validationErrors?: unknown;
|
||||
};
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
survey: TSurveyListItem;
|
||||
@@ -28,6 +45,10 @@ interface SurveyDropDownMenuProps {
|
||||
disabled?: boolean;
|
||||
isSurveyCreationDeletionDisabled?: boolean;
|
||||
deleteSurvey: (surveyId: string) => Promise<void>;
|
||||
updateSurveyStatus: (variables: {
|
||||
surveyId: string;
|
||||
status: TSurveyStatusUpdate;
|
||||
}) => Promise<TUpdateSurveyStatusResponse>;
|
||||
}
|
||||
|
||||
export const SurveyDropDownMenu = ({
|
||||
@@ -36,7 +57,8 @@ export const SurveyDropDownMenu = ({
|
||||
disabled,
|
||||
isSurveyCreationDeletionDisabled,
|
||||
deleteSurvey,
|
||||
}: SurveyDropDownMenuProps) => {
|
||||
updateSurveyStatus,
|
||||
}: Readonly<SurveyDropDownMenuProps>) => {
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -49,11 +71,26 @@ export const SurveyDropDownMenu = ({
|
||||
const editHref = `/workspaces/${workspace?.id}/surveys/${survey.id}/edit`;
|
||||
|
||||
const surveyLink = useMemo(() => `${publicDomain}/s/${survey.id}`, [publicDomain, survey.id]);
|
||||
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
|
||||
const isSingleUseEnabled = survey.singleUse?.enabled ?? false;
|
||||
const canManageSurvey = !isSurveyCreationDeletionDisabled;
|
||||
const canUpdateSurveyStatus = canManageSurvey && survey.status !== "draft";
|
||||
const canPreviewOrCopyLink = survey.type === "link" && survey.status !== "draft";
|
||||
const hasVisibleActions = canManageSurvey || canPreviewOrCopyLink;
|
||||
|
||||
const getSurveyStatusLabel = (status: TSurveyListItem["status"], isScheduledStatus = isScheduled) => {
|
||||
switch (status) {
|
||||
case "inProgress":
|
||||
return t("common.in_progress");
|
||||
case "completed":
|
||||
return t("common.completed");
|
||||
case "draft":
|
||||
return t("common.draft");
|
||||
case "paused":
|
||||
return isScheduledStatus ? t("common.scheduled") : t("common.paused");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSurvey = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -85,6 +122,40 @@ export const SurveyDropDownMenu = ({
|
||||
setIsCautionDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (status: TSurveyStatusUpdate) => {
|
||||
if (status === survey.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDropDownOpen(false);
|
||||
const toastId = toast.loading(t("common.saving"));
|
||||
|
||||
try {
|
||||
const updateSurveyStatusResponse = await updateSurveyStatus({ surveyId: survey.id, status });
|
||||
|
||||
if (updateSurveyStatusResponse?.data) {
|
||||
const { publishOn, status: resultingStatus } = updateSurveyStatusResponse.data;
|
||||
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
|
||||
const statusToToastMessage: Record<TSurveyStatusUpdate, string> = {
|
||||
inProgress: t("common.survey_live"),
|
||||
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
|
||||
completed: t("common.survey_completed"),
|
||||
};
|
||||
|
||||
if (resultingStatus !== "draft") {
|
||||
toast.success(statusToToastMessage[resultingStatus], { id: toastId });
|
||||
} else {
|
||||
toast.success(t("workspace.surveys.edit.changes_saved"), { id: toastId });
|
||||
}
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(updateSurveyStatusResponse), { id: toastId });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
toast.error(t("common.something_went_wrong_please_try_again"), { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasVisibleActions) {
|
||||
return null;
|
||||
}
|
||||
@@ -120,6 +191,39 @@ export const SurveyDropDownMenu = ({
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canUpdateSurveyStatus && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<div className="flex min-w-36 flex-1 items-center gap-2">
|
||||
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
|
||||
<span>{t("common.status")}</span>
|
||||
<span className="ml-auto pl-4 text-xs font-normal text-slate-500">
|
||||
{getSurveyStatusLabel(survey.status)}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={survey.status}
|
||||
onValueChange={(value) => {
|
||||
void handleStatusChange(value as TSurveyStatusUpdate);
|
||||
}}>
|
||||
<DropdownMenuRadioItem value="inProgress">
|
||||
<SurveyStatusIndicator status="inProgress" />
|
||||
{getSurveyStatusLabel("inProgress", false)}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="paused">
|
||||
<SurveyStatusIndicator status="paused" isScheduled={isScheduled} />
|
||||
{getSurveyStatusLabel("paused")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="completed">
|
||||
<SurveyStatusIndicator status="completed" />
|
||||
{getSurveyStatusLabel("completed", false)}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
{canPreviewOrCopyLink && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
|
||||
@@ -11,6 +11,7 @@ import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||
import { getV3ApiErrorMessage } from "@/modules/api/lib/v3-client";
|
||||
import { useDeleteSurvey } from "@/modules/survey/list/hooks/use-delete-survey";
|
||||
import { useSurveys } from "@/modules/survey/list/hooks/use-surveys";
|
||||
import { useUpdateSurveyStatus } from "@/modules/survey/list/hooks/use-update-survey-status";
|
||||
import { initialFilters } from "@/modules/survey/list/lib/constants";
|
||||
import {
|
||||
hasActiveSurveyFilters,
|
||||
@@ -44,7 +45,7 @@ export const SurveysList = ({
|
||||
surveysPerPage,
|
||||
currentWorkspaceChannel,
|
||||
locale,
|
||||
}: SurveysListProps) => {
|
||||
}: Readonly<SurveysListProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [surveyFilters, setSurveyFilters] = useState<TSurveyOverviewFilters>(initialFilters);
|
||||
const [isFilterInitialized, setIsFilterInitialized] = useState(false);
|
||||
@@ -103,6 +104,7 @@ export const SurveysList = ({
|
||||
});
|
||||
|
||||
const deleteSurveyMutation = useDeleteSurvey({ queryKey });
|
||||
const updateSurveyStatusMutation = useUpdateSurveyStatus({ queryKey });
|
||||
|
||||
const hasAppliedFilters = hasActiveSurveyFilters(normalizedFilters);
|
||||
const showInitialLoading = !isFilterInitialized || (isLoading && surveys.length === 0);
|
||||
@@ -113,6 +115,10 @@ export const SurveysList = ({
|
||||
await deleteSurveyMutation.mutateAsync({ surveyId });
|
||||
};
|
||||
|
||||
const handleUpdateSurveyStatus = async (
|
||||
variables: Parameters<typeof updateSurveyStatusMutation.mutateAsync>[0]
|
||||
) => updateSurveyStatusMutation.mutateAsync(variables);
|
||||
|
||||
const createSurveyButton = (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspace.id}/surveys/templates`}>
|
||||
@@ -203,6 +209,7 @@ export const SurveysList = ({
|
||||
survey={survey}
|
||||
isReadOnly={isReadOnly}
|
||||
deleteSurvey={handleDeleteSurvey}
|
||||
updateSurveyStatus={handleUpdateSurveyStatus}
|
||||
publicDomain={publicDomain}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { type ReactNode, createElement } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { updateSurveyStatusAction } from "@/modules/survey/list/actions";
|
||||
import { surveyKeys } from "@/modules/survey/list/lib/query";
|
||||
import type { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
|
||||
import { useUpdateSurveyStatus } from "./use-update-survey-status";
|
||||
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
updateSurveyStatusAction: vi.fn(),
|
||||
}));
|
||||
|
||||
const queryKey = surveyKeys.list({
|
||||
workspaceId: "workspace_1",
|
||||
limit: 20,
|
||||
filters: {
|
||||
name: "",
|
||||
status: [],
|
||||
type: [],
|
||||
sortBy: "relevance",
|
||||
},
|
||||
});
|
||||
|
||||
const queryData = {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
id: "survey_1",
|
||||
name: "Survey 1",
|
||||
workspaceId: "workspace_1",
|
||||
type: "link",
|
||||
status: "inProgress",
|
||||
publishOn: null,
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
creator: { name: "Alice" },
|
||||
singleUse: null,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
limit: 20,
|
||||
nextCursor: null,
|
||||
totalCount: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
} satisfies { pages: TSurveyListPage[]; pageParams: (string | null)[] };
|
||||
|
||||
const createWrapper = (queryClient: QueryClient) => {
|
||||
const Wrapper = ({ children }: Readonly<{ children: ReactNode }>) =>
|
||||
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
Wrapper.displayName = "UseUpdateSurveyStatusTestWrapper";
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
mutations: { retry: false },
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
describe("useUpdateSurveyStatus", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("updates cached list data from the status action result", async () => {
|
||||
const updatedAt = new Date("2026-04-16T10:00:00.000Z");
|
||||
vi.mocked(updateSurveyStatusAction).mockResolvedValue({
|
||||
data: {
|
||||
id: "survey_1",
|
||||
status: "completed",
|
||||
publishOn: null,
|
||||
updatedAt,
|
||||
},
|
||||
});
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(queryKey, queryData);
|
||||
|
||||
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
result.current.mutate({ surveyId: "survey_1", status: "completed" });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(queryClient.getQueryData<{ pages: TSurveyListPage[] }>(queryKey)?.pages[0]?.data[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "completed",
|
||||
publishOn: null,
|
||||
updatedAt,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("rolls cached list data back when the action throws", async () => {
|
||||
vi.mocked(updateSurveyStatusAction).mockRejectedValue(new Error("Unable to update"));
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(queryKey, queryData);
|
||||
|
||||
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
result.current.mutate({ surveyId: "survey_1", status: "completed" });
|
||||
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual(queryData);
|
||||
});
|
||||
|
||||
test("rolls cached list data back when the action returns no data", async () => {
|
||||
vi.mocked(updateSurveyStatusAction).mockResolvedValue({ serverError: "Unable to update" });
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
queryClient.setQueryData(queryKey, queryData);
|
||||
|
||||
const { result } = renderHook(() => useUpdateSurveyStatus({ queryKey }), {
|
||||
wrapper: createWrapper(queryClient),
|
||||
});
|
||||
|
||||
result.current.mutate({ surveyId: "survey_1", status: "completed" });
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(queryClient.getQueryData(queryKey)).toEqual(queryData);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { InfiniteData, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TSurveyStatus } from "@formbricks/types/surveys/types";
|
||||
import { updateSurveyStatusAction } from "@/modules/survey/list/actions";
|
||||
import { surveyKeys, updateSurveyInInfiniteData } from "@/modules/survey/list/lib/query";
|
||||
import type { TSurveyListPage } from "@/modules/survey/list/lib/v3-surveys-client";
|
||||
|
||||
type TUpdateSurveyStatusInput = {
|
||||
surveyId: string;
|
||||
status: Exclude<TSurveyStatus, "draft">;
|
||||
};
|
||||
|
||||
export const useUpdateSurveyStatus = ({ queryKey }: { queryKey: ReturnType<typeof surveyKeys.list> }) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ surveyId, status }: TUpdateSurveyStatusInput) =>
|
||||
updateSurveyStatusAction({ surveyId, status }),
|
||||
onMutate: async ({ surveyId, status }) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
const previousData = queryClient.getQueryData<InfiniteData<TSurveyListPage>>(queryKey);
|
||||
|
||||
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
|
||||
updateSurveyInInfiniteData(currentData, {
|
||||
id: surveyId,
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
...(status === "paused" ? {} : { publishOn: null }),
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
previousData,
|
||||
};
|
||||
},
|
||||
onError: (_error, _variables, context) => {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(queryKey, context.previousData);
|
||||
}
|
||||
},
|
||||
onSuccess: (response, _variables, context) => {
|
||||
if (!response?.data) {
|
||||
if (context?.previousData) {
|
||||
queryClient.setQueryData(queryKey, context.previousData);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { id, publishOn, status, updatedAt } = response.data;
|
||||
|
||||
queryClient.setQueryData<InfiniteData<TSurveyListPage> | undefined>(queryKey, (currentData) =>
|
||||
updateSurveyInInfiniteData(currentData, {
|
||||
id,
|
||||
publishOn,
|
||||
status,
|
||||
updatedAt,
|
||||
})
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: surveyKeys.lists() });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { flattenSurveyPages, removeSurveyFromInfiniteData } from "./query";
|
||||
import { TSurveyListPage } from "./v3-surveys-client";
|
||||
import { flattenSurveyPages, removeSurveyFromInfiniteData, updateSurveyInInfiniteData } from "./query";
|
||||
import type { TSurveyListPage } from "./v3-surveys-client";
|
||||
|
||||
const surveyA = {
|
||||
id: "survey_a",
|
||||
@@ -9,6 +9,7 @@ const surveyA = {
|
||||
workspaceId: "env_1",
|
||||
type: "link" as const,
|
||||
status: "draft" as const,
|
||||
publishOn: null,
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
@@ -50,6 +51,30 @@ describe("flattenSurveyPages", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateSurveyInInfiniteData", () => {
|
||||
test("updates the matching survey across cached pages", () => {
|
||||
const updatedAt = new Date("2026-04-16T10:00:00.000Z");
|
||||
const nextData = updateSurveyInInfiniteData(baseData, {
|
||||
id: "survey_b",
|
||||
status: "completed",
|
||||
publishOn: null,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
expect(nextData?.pages[0]?.data).toEqual([surveyA]);
|
||||
expect(nextData?.pages[1]?.data[0]).toEqual({
|
||||
...surveyB,
|
||||
status: "completed",
|
||||
publishOn: null,
|
||||
updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns the original cache when the survey is not present", () => {
|
||||
expect(updateSurveyInInfiniteData(baseData, { id: "missing_survey", status: "paused" })).toBe(baseData);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeSurveyFromInfiniteData", () => {
|
||||
test("removes the survey from cached pages and decrements each page total", () => {
|
||||
const nextData = removeSurveyFromInfiniteData(baseData, "survey_a");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import { TSurveyListPage } from "./v3-surveys-client";
|
||||
import type { TSurveyListItem, TSurveyOverviewFilters } from "@/modules/survey/list/types/survey-overview";
|
||||
import type { TSurveyListPage } from "./v3-surveys-client";
|
||||
|
||||
type TSurveyListKeyInput = {
|
||||
workspaceId: string;
|
||||
@@ -55,3 +55,38 @@ export function removeSurveyFromInfiniteData(
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateSurveyInInfiniteData(
|
||||
data: InfiniteData<TSurveyListPage> | undefined,
|
||||
updatedSurvey: Pick<TSurveyListItem, "id"> & Partial<TSurveyListItem>
|
||||
): InfiniteData<TSurveyListPage> | undefined {
|
||||
if (!data) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let surveyWasUpdated = false;
|
||||
|
||||
const pages = data.pages.map((page) => ({
|
||||
...page,
|
||||
data: page.data.map((survey) => {
|
||||
if (survey.id !== updatedSurvey.id) {
|
||||
return survey;
|
||||
}
|
||||
|
||||
surveyWasUpdated = true;
|
||||
return {
|
||||
...survey,
|
||||
...updatedSurvey,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
if (!surveyWasUpdated) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
pages,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user