mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-08 06:41:45 -05:00
chore: Remove the public result sharing page. (#6298)
Co-authored-by: Victor Santos <victor@formbricks.com>
This commit is contained in:
@@ -18,7 +18,6 @@ apps/web/
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
|
||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
-2
@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
-1
@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
-2
@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
-2
@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
|
||||
languages: [],
|
||||
styling: null,
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
closeOnDate: null,
|
||||
runOnDate: null,
|
||||
|
||||
-18
@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||
vi.mock("@/app/lib/surveys/surveys");
|
||||
vi.mock("@/app/share/[sharingKey]/actions");
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||
}));
|
||||
@@ -112,7 +111,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
@@ -171,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("renders navigation correctly for sharing page", () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
|
||||
);
|
||||
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
|
||||
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
|
||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
|
||||
|
||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
||||
|
||||
expect(MockSecondaryNavigation).toHaveBeenCalled();
|
||||
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
||||
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
|
||||
});
|
||||
|
||||
test("displays correct response count string in label for various scenarios", async () => {
|
||||
mockUsePathname.mockReturnValue(
|
||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
||||
|
||||
+2
-5
@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SurveyAnalysisNavigationProps {
|
||||
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
|
||||
}: SurveyAnalysisNavigationProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
|
||||
-1
@@ -50,7 +50,6 @@ const mockSurvey = {
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
recontactDays: null,
|
||||
resultShareKey: null,
|
||||
runOnDate: null,
|
||||
showLanguageSwitch: false,
|
||||
singleUse: null,
|
||||
|
||||
-1
@@ -113,7 +113,6 @@ const mockSurvey = {
|
||||
singleUse: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
||||
styling: null,
|
||||
|
||||
-1
@@ -88,7 +88,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
-34
@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
||||
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
getFormattedFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn(),
|
||||
getResponsesBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: vi.fn((survey) => survey),
|
||||
}));
|
||||
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
||||
.getResponseCountAction
|
||||
);
|
||||
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
|
||||
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
|
||||
);
|
||||
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
|
||||
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
|
||||
);
|
||||
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
|
||||
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
||||
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
||||
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
|
||||
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
||||
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
||||
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
||||
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
|
||||
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
|
||||
mockGetFormattedFilters.mockReturnValue({});
|
||||
});
|
||||
|
||||
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
|
||||
render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not render ResultsShareButton when isReadOnly is true", async () => {
|
||||
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not render ResultsShareButton when on sharing page", async () => {
|
||||
mockUseParams.mockReturnValue({ sharingKey: "share123" });
|
||||
render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("fetches next page of responses", async () => {
|
||||
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
||||
await waitFor(() => {
|
||||
|
||||
+17
-47
@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
|
||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
@@ -20,7 +18,6 @@ interface ResponsePageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
publicDomain: string;
|
||||
user?: TUser;
|
||||
environmentTags: TTag[];
|
||||
responsesPerPage: number;
|
||||
@@ -32,17 +29,12 @@ export const ResponsePage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
publicDomain,
|
||||
user,
|
||||
environmentTags,
|
||||
responsesPerPage,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: ResponsePageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||
@@ -63,30 +55,20 @@ export const ResponsePage = ({
|
||||
|
||||
let newResponses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
||||
sharingKey: sharingKey,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
} else {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
}
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: (newPage - 1) * responsesPerPage,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
newResponses = getResponsesActionResponse?.data || [];
|
||||
|
||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
}
|
||||
setResponses([...responses, ...newResponses]);
|
||||
setPage(newPage);
|
||||
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
|
||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||
|
||||
const deleteResponses = (responseIds: string[]) => {
|
||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||
@@ -114,25 +96,14 @@ export const ResponsePage = ({
|
||||
setFetchingFirstPage(true);
|
||||
let responses: TResponse[] = [];
|
||||
|
||||
if (isSharingPage) {
|
||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
} else {
|
||||
const getResponsesActionResponse = await getResponsesAction({
|
||||
surveyId,
|
||||
limit: responsesPerPage,
|
||||
offset: 0,
|
||||
filterCriteria: filters,
|
||||
});
|
||||
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
}
|
||||
responses = getResponsesActionResponse?.data || [];
|
||||
|
||||
if (responses.length < responsesPerPage) {
|
||||
setHasMore(false);
|
||||
@@ -143,7 +114,7 @@ export const ResponsePage = ({
|
||||
}
|
||||
};
|
||||
fetchInitialResponses();
|
||||
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
|
||||
}, [surveyId, filters, responsesPerPage]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
@@ -155,7 +126,6 @@ export const ResponsePage = ({
|
||||
<>
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
||||
</div>
|
||||
<ResponseDataView
|
||||
survey={survey}
|
||||
|
||||
-1
@@ -156,7 +156,6 @@ const mockSurvey = {
|
||||
projectOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
|
||||
-3
@@ -120,7 +120,6 @@ vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -232,12 +231,10 @@ describe("ResponsesPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
publicDomain: mockPublicDomain,
|
||||
environmentTags: mockTags,
|
||||
user: mockUser,
|
||||
responsesPerPage: 10,
|
||||
locale: mockLocale,
|
||||
isReadOnly: false,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
-1
@@ -70,7 +70,6 @@ const Page = async (props) => {
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
user={user}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
|
||||
-139
@@ -14,7 +14,6 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
@@ -65,144 +64,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||
);
|
||||
});
|
||||
|
||||
const ZGenerateResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const generateResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGenerateResultShareUrlAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
const resultShareKey = customAlphabet(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
||||
20
|
||||
)();
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return resultShareKey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const getResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZGetResultShareUrlAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
return survey.resultShareKey;
|
||||
});
|
||||
|
||||
const ZDeleteResultShareUrlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
.schema(ZDeleteResultShareUrlAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = survey;
|
||||
|
||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
|
||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
||||
|
||||
return newSurvey;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
|
||||
-150
@@ -1,150 +0,0 @@
|
||||
import { ShareSurveyResults } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock Button
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: vi.fn(({ children, onClick, asChild, ...props }: any) => {
|
||||
if (asChild) {
|
||||
// For 'asChild', Button renders its children, potentially passing props via Slot.
|
||||
// Mocking simply renders children inside a div that can receive Button's props.
|
||||
return <div {...props}>{children}</div>;
|
||||
}
|
||||
return (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Dialog
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: vi.fn(({ children, open, onOpenChange }) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
DialogContent: vi.fn(({ children, ...props }) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: vi.fn(() => ({
|
||||
t: (key: string) => key,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock Next Link
|
||||
vi.mock("next/link", () => ({
|
||||
default: vi.fn(({ children, href, target, rel, ...props }) => (
|
||||
<a href={href} target={target} rel={rel} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockHandlePublish = vi.fn();
|
||||
const mockHandleUnpublish = vi.fn();
|
||||
const surveyUrl = "https://app.formbricks.com/s/some-survey-id";
|
||||
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
handlePublish: mockHandlePublish,
|
||||
handleUnpublish: mockHandleUnpublish,
|
||||
showPublishModal: false,
|
||||
surveyUrl: "",
|
||||
};
|
||||
|
||||
describe("ShareSurveyResults", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(global.navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders publish warning when showPublishModal is false", async () => {
|
||||
render(<ShareSurveyResults {...defaultProps} />);
|
||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.publish_to_web_warning_description")
|
||||
).toBeInTheDocument();
|
||||
const publishButton = screen.getByText("environments.surveys.summary.publish_to_web");
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
await userEvent.click(publishButton);
|
||||
expect(mockHandlePublish).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("renders survey public info when showPublishModal is true and surveyUrl is provided", async () => {
|
||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl={surveyUrl} />);
|
||||
expect(screen.getByText("environments.surveys.summary.survey_results_are_public")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(surveyUrl)).toBeInTheDocument();
|
||||
|
||||
const copyButton = screen.getByRole("button", { name: "Copy survey link to clipboard" });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await userEvent.click(copyButton);
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
|
||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("common.link_copied");
|
||||
|
||||
const unpublishButton = screen.getByText("environments.surveys.summary.unpublish_from_web");
|
||||
expect(unpublishButton).toBeInTheDocument();
|
||||
await userEvent.click(unpublishButton);
|
||||
expect(mockHandleUnpublish).toHaveBeenCalledTimes(1);
|
||||
|
||||
const viewSiteLink = screen.getByText("environments.surveys.summary.view_site");
|
||||
expect(viewSiteLink).toBeInTheDocument();
|
||||
const anchor = viewSiteLink.closest("a");
|
||||
expect(anchor).toHaveAttribute("href", surveyUrl);
|
||||
expect(anchor).toHaveAttribute("target", "_blank");
|
||||
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
test("does not render content when modal is closed (open is false)", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders publish warning if surveyUrl is empty even if showPublishModal is true", () => {
|
||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl="" />);
|
||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-97
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
interface ShareEmbedSurveyProps {
|
||||
open: boolean;
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handlePublish: () => void;
|
||||
handleUnpublish: () => void;
|
||||
showPublishModal: boolean;
|
||||
surveyUrl: string;
|
||||
}
|
||||
export const ShareSurveyResults = ({
|
||||
open,
|
||||
setOpen,
|
||||
handlePublish,
|
||||
handleUnpublish,
|
||||
showPublishModal,
|
||||
surveyUrl,
|
||||
}: ShareEmbedSurveyProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogBody>
|
||||
{showPublishModal && surveyUrl ? (
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
||||
<div>
|
||||
<p className="text-primary text-lg font-medium">
|
||||
{t("environments.surveys.summary.survey_results_are_public")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
||||
<span>{surveyUrl}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
title="Copy survey link to clipboard"
|
||||
aria-label="Copy survey link to clipboard"
|
||||
className="hover:cursor-pointer"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("common.link_copied"));
|
||||
}}>
|
||||
<Clipboard />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="text-center"
|
||||
onClick={() => handleUnpublish()}>
|
||||
{t("environments.surveys.summary.unpublish_from_web")}
|
||||
</Button>
|
||||
<Button className="text-center" asChild>
|
||||
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("environments.surveys.summary.view_site")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
||||
<div>
|
||||
<p className="text-lg font-medium text-slate-600">
|
||||
{t("environments.surveys.summary.publish_to_web_warning")}
|
||||
</p>
|
||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.publish_to_web_warning_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
|
||||
{t("environments.surveys.summary.publish_to_web")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
-1
@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
|
||||
surveyClosedMessage: null,
|
||||
hiddenFields: { enabled: false, fieldIds: [] },
|
||||
variables: [],
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
-1
@@ -177,7 +177,6 @@ const mockSurvey = {
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
-54
@@ -44,43 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
||||
@@ -125,10 +88,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
||||
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
|
||||
}));
|
||||
|
||||
// Mock context
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: () => ({
|
||||
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
|
||||
webAppUrl: "https://app.example.com",
|
||||
totalResponseCount: 50,
|
||||
locale,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||
});
|
||||
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
|
||||
// Drop-offs should now be visible
|
||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show share button in read-only mode", async () => {
|
||||
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+6
-26
@@ -5,11 +5,9 @@ import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
@@ -36,9 +34,7 @@ interface SummaryPageProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
surveyId: string;
|
||||
publicDomain: string;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
initialSurveySummary?: TSurveySummary;
|
||||
}
|
||||
|
||||
@@ -46,15 +42,9 @@ export const SummaryPage = ({
|
||||
environment,
|
||||
survey,
|
||||
surveyId,
|
||||
publicDomain,
|
||||
locale,
|
||||
isReadOnly,
|
||||
initialSurveySummary,
|
||||
}: SummaryPageProps) => {
|
||||
const params = useParams();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
@@ -87,17 +77,10 @@ export const SummaryPage = ({
|
||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||
let updatedSurveySummary;
|
||||
|
||||
if (isSharingPage) {
|
||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
} else {
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
}
|
||||
updatedSurveySummary = await getSurveySummaryAction({
|
||||
surveyId,
|
||||
filterCriteria: currentFilters,
|
||||
});
|
||||
|
||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||
setSurveySummary(surveySummary);
|
||||
@@ -109,7 +92,7 @@ export const SummaryPage = ({
|
||||
};
|
||||
|
||||
fetchSummary();
|
||||
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
||||
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||
|
||||
const surveyMemoized = useMemo(() => {
|
||||
return replaceHeadlineRecall(survey, "default");
|
||||
@@ -132,9 +115,6 @@ export const SummaryPage = ({
|
||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
<div className="flex gap-1.5">
|
||||
<CustomFilter survey={surveyMemoized} />
|
||||
{!isReadOnly && !isSharingPage && (
|
||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
||||
)}
|
||||
</div>
|
||||
<ScrollToTop containerId="mainContent" />
|
||||
<SummaryList
|
||||
|
||||
-12
@@ -284,7 +284,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockUser: TUser = {
|
||||
@@ -375,14 +374,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
});
|
||||
|
||||
test("shows public results badge when resultShareKey exists", () => {
|
||||
const surveyWithShareKey = { ...mockSurvey, resultShareKey: "share-key" };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithShareKey} />);
|
||||
|
||||
expect(screen.getByTestId("badge")).toBeInTheDocument();
|
||||
expect(screen.getByText("Results are public")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
@@ -506,7 +497,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
@@ -588,7 +578,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
}),
|
||||
@@ -622,7 +611,6 @@ describe("SurveyAnalysisCTA", () => {
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
-10
@@ -8,7 +8,6 @@ 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 { 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";
|
||||
@@ -185,15 +184,6 @@ export const SurveyAnalysisCTA = ({
|
||||
|
||||
return (
|
||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||
{survey.resultShareKey && (
|
||||
<Badge
|
||||
type="warning"
|
||||
size="normal"
|
||||
className="rounded-lg"
|
||||
text={t("environments.surveys.summary.results_are_public")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||
)}
|
||||
|
||||
-1
@@ -227,7 +227,6 @@ const mockSurvey: TSurvey = {
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockAppSurvey: TSurvey = {
|
||||
|
||||
-1
@@ -81,7 +81,6 @@ const mockSurvey = {
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
variables: [],
|
||||
segment: null,
|
||||
autoClose: null,
|
||||
|
||||
-1
@@ -93,7 +93,6 @@ const mockBaseSurvey: TSurvey = {
|
||||
environmentId: "env_123",
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
createdBy: "user_123",
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
|
||||
-1
@@ -66,7 +66,6 @@ describe("Utils Tests", () => {
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
displayOption: "displayOnce",
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
createdAt: new Date(),
|
||||
|
||||
-4
@@ -111,7 +111,6 @@ vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({
|
||||
environmentId: "test-environment-id",
|
||||
surveyId: "test-survey-id",
|
||||
sharingKey: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -145,7 +144,6 @@ const mockSurvey = {
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
resultShareKey: null,
|
||||
runOnDate: null,
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
@@ -249,8 +247,6 @@ describe("SurveyPage", () => {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: mockSurveyId,
|
||||
publicDomain: "http://localhost:3000",
|
||||
isReadOnly: false,
|
||||
locale: mockUser.locale ?? DEFAULT_LOCALE,
|
||||
initialSurveySummary: mockSurveySummary,
|
||||
})
|
||||
|
||||
-2
@@ -70,8 +70,6 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
publicDomain={publicDomain}
|
||||
isReadOnly={isReadOnly}
|
||||
locale={user.locale ?? DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
|
||||
-11
@@ -102,7 +102,6 @@ const mockSurvey = {
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
@@ -157,16 +156,6 @@ describe("CustomFilter", () => {
|
||||
expect(screen.getByText(`Select first date - ${format(mockDateToday, "dd LLL")}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render download button on sharing page", () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "test-env",
|
||||
surveyId: "test-survey",
|
||||
sharingKey: "test-share-key",
|
||||
});
|
||||
render(<CustomFilter survey={mockSurvey} />);
|
||||
expect(screen.queryByText("common.download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("useEffect logic for resetState and firstMountRef (as per current component code)", () => {
|
||||
// This test verifies the current behavior of the useEffects related to firstMountRef.
|
||||
// Based on the component's code, resetState() is not expected to be called by these effects,
|
||||
|
||||
+39
-46
@@ -32,7 +32,6 @@ import {
|
||||
subYears,
|
||||
} from "date-fns";
|
||||
import { ArrowDownToLineIcon, ChevronDown, ChevronUp, DownloadIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -125,8 +124,6 @@ const getDateRangeLabel = (from: Date, to: Date, t: TFnType) => {
|
||||
};
|
||||
|
||||
export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
const params = useParams();
|
||||
const isSharingPage = !!params.sharingKey;
|
||||
const { t } = useTranslate();
|
||||
const { selectedFilter, dateRange, setDateRange, resetState } = useResponseFilter();
|
||||
const [filterRange, setFilterRange] = useState(
|
||||
@@ -385,51 +382,47 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isSharingPage && (
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
<DropdownMenu
|
||||
onOpenChange={(value) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">
|
||||
{t("environments.surveys.summary.filtered_responses_excel")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.ALL, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.all_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "csv");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_csv")}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
handleDowndloadResponses(FilterDownload.FILTER, "xlsx");
|
||||
}}>
|
||||
<p className="text-slate-700">{t("environments.surveys.summary.filtered_responses_excel")}</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{isDatePickerOpen && (
|
||||
<div ref={datePickerRef} className="absolute top-full z-50 my-2 rounded-md border bg-white">
|
||||
|
||||
-30
@@ -1,7 +1,6 @@
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -19,10 +18,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", (
|
||||
getSurveyFilterDataAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getSurveyFilterDataBySurveySharingKeyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||
generateQuestionAndFilterOptions: vi.fn(),
|
||||
}));
|
||||
@@ -235,29 +230,4 @@ describe("ResponseFilter", () => {
|
||||
|
||||
expect(mockSetSelectedFilter).toHaveBeenCalledWith({ filter: [], onlyComplete: false });
|
||||
});
|
||||
|
||||
test("uses sharing key action when on sharing page", async () => {
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
environmentId: "env1",
|
||||
surveyId: "survey1",
|
||||
sharingKey: "share123",
|
||||
});
|
||||
vi.mocked(getSurveyFilterDataBySurveySharingKeyAction).mockResolvedValue({
|
||||
data: {
|
||||
attributes: [],
|
||||
meta: {},
|
||||
environmentTags: [],
|
||||
hiddenFields: [],
|
||||
} as any,
|
||||
});
|
||||
|
||||
render(<ResponseFilter survey={mockSurvey} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Filter"));
|
||||
|
||||
expect(getSurveyFilterDataBySurveySharingKeyAction).toHaveBeenCalledWith({
|
||||
sharingKey: "share123",
|
||||
environmentId: "env1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+2
-12
@@ -7,7 +7,6 @@ import {
|
||||
import { getSurveyFilterDataAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { QuestionFilterComboBox } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox";
|
||||
import { generateQuestionAndFilterOptions } from "@/app/lib/surveys/surveys";
|
||||
import { getSurveyFilterDataBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||
@@ -15,7 +14,6 @@ import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown, ChevronUp, Plus, TrashIcon } from "lucide-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
@@ -33,10 +31,7 @@ interface ResponseFilterProps {
|
||||
|
||||
export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
const { t } = useTranslate();
|
||||
const params = useParams();
|
||||
const [parent] = useAutoAnimate();
|
||||
const sharingKey = params.sharingKey as string;
|
||||
const isSharingPage = !!sharingKey;
|
||||
|
||||
const { selectedFilter, setSelectedFilter, selectedOptions, setSelectedOptions } = useResponseFilter();
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
@@ -46,12 +41,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
// Fetch the initial data for the filter and load it into the state
|
||||
const handleInitialData = async () => {
|
||||
if (isOpen) {
|
||||
const surveyFilterData = isSharingPage
|
||||
? await getSurveyFilterDataBySurveySharingKeyAction({
|
||||
sharingKey,
|
||||
environmentId: survey.environmentId,
|
||||
})
|
||||
: await getSurveyFilterDataAction({ surveyId: survey.id });
|
||||
const surveyFilterData = await getSurveyFilterDataAction({ surveyId: survey.id });
|
||||
|
||||
if (!surveyFilterData?.data) return;
|
||||
|
||||
@@ -68,7 +58,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
};
|
||||
|
||||
handleInitialData();
|
||||
}, [isOpen, isSharingPage, setSelectedOptions, sharingKey, survey]);
|
||||
}, [isOpen, setSelectedOptions, survey]);
|
||||
|
||||
const handleOnChangeQuestionComboBoxValue = (value: QuestionOption, index: number) => {
|
||||
if (filterValue.filter[index].questionType) {
|
||||
|
||||
-261
@@ -1,261 +0,0 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ResultsShareButton } from "./ResultsShareButton";
|
||||
|
||||
// Mock actions
|
||||
const mockDeleteResultShareUrlAction = vi.fn();
|
||||
const mockGenerateResultShareUrlAction = vi.fn();
|
||||
const mockGetResultShareUrlAction = vi.fn();
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions", () => ({
|
||||
deleteResultShareUrlAction: (...args) => mockDeleteResultShareUrlAction(...args),
|
||||
generateResultShareUrlAction: (...args) => mockGenerateResultShareUrlAction(...args),
|
||||
getResultShareUrlAction: (...args) => mockGetResultShareUrlAction(...args),
|
||||
}));
|
||||
|
||||
// Mock helper
|
||||
const mockGetFormattedErrorMessage = vi.fn((error) => error?.message || "An error occurred");
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: (error) => mockGetFormattedErrorMessage(error),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }) => <div data-testid="dropdown-menu">{children}</div>,
|
||||
DropdownMenuContent: ({ children, align }) => (
|
||||
<div data-testid="dropdown-menu-content" data-align={align}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuItem: ({ children, onClick, icon }) => (
|
||||
<button data-testid="dropdown-menu-item" onClick={onClick}>
|
||||
{icon}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children }) => <div data-testid="dropdown-menu-trigger">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Tolgee
|
||||
const mockT = vi.fn((key) => key);
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: mockT }),
|
||||
}));
|
||||
|
||||
// Mock icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon" />,
|
||||
DownloadIcon: () => <div data-testid="download-icon" />,
|
||||
GlobeIcon: () => <div data-testid="globe-icon" />,
|
||||
LinkIcon: () => <div data-testid="link-icon" />,
|
||||
}));
|
||||
|
||||
// Mock toast
|
||||
const mockToastSuccess = vi.fn();
|
||||
const mockToastError = vi.fn();
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: (...args) => mockToastSuccess(...args),
|
||||
error: (...args) => mockToastError(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock ShareSurveyResults component
|
||||
const mockShareSurveyResults = vi.fn();
|
||||
vi.mock("../(analysis)/summary/components/ShareSurveyResults", () => ({
|
||||
ShareSurveyResults: (props) => {
|
||||
mockShareSurveyResults(props);
|
||||
return props.open ? (
|
||||
<div data-testid="share-survey-results-modal">
|
||||
<span>ShareSurveyResults Modal</span>
|
||||
<button onClick={() => props.setOpen(false)}>Close Modal</button>
|
||||
<button data-testid="handle-publish-button" onClick={props.handlePublish}>
|
||||
Publish
|
||||
</button>
|
||||
<button data-testid="handle-unpublish-button" onClick={props.handleUnpublish}>
|
||||
Unpublish
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
},
|
||||
}));
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
hiddenFields: { enabled: false },
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: 0,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
styling: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
variables: [],
|
||||
closeOnDate: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const webAppUrl = "https://app.formbricks.com";
|
||||
const originalLocation = window.location;
|
||||
|
||||
describe("ResultsShareButton", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.location.href
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...originalLocation, href: "https://app.formbricks.com/surveys/survey1" },
|
||||
});
|
||||
// Mock navigator.clipboard
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
|
||||
test("renders initial state and fetches sharing key (no existing key)", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
expect(screen.getByTestId("dropdown-menu-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
expect(mockGetResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles copy private link to clipboard", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("common.copy_link")
|
||||
);
|
||||
expect(copyLinkButton).toBeInTheDocument();
|
||||
await userEvent.click(copyLinkButton!);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(window.location.href);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("handles copy public link to clipboard", async () => {
|
||||
const shareKey = "publicShareKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger")); // Open dropdown
|
||||
const copyPublicLinkButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.copy_link_to_public_results")
|
||||
);
|
||||
expect(copyPublicLinkButton).toBeInTheDocument();
|
||||
await userEvent.click(copyPublicLinkButton!);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`${webAppUrl}/share/${shareKey}`);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
"environments.surveys.summary.link_to_public_results_copied"
|
||||
);
|
||||
});
|
||||
|
||||
test("handles publish to web successfully", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
mockGenerateResultShareUrlAction.mockResolvedValue({ data: "newShareKey" });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.publish_to_web")
|
||||
);
|
||||
await userEvent.click(publishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("handle-publish-button"));
|
||||
|
||||
expect(mockGenerateResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
await waitFor(() => {
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
surveyUrl: `${webAppUrl}/share/newShareKey`,
|
||||
showPublishModal: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("handles unpublish from web successfully", async () => {
|
||||
const shareKey = "toUnpublishKey";
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: shareKey });
|
||||
mockDeleteResultShareUrlAction.mockResolvedValue({ data: { id: mockSurvey.id } });
|
||||
render(
|
||||
<ResultsShareButton survey={{ ...mockSurvey, resultShareKey: shareKey }} publicDomain={webAppUrl} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const unpublishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.unpublish_from_web")
|
||||
);
|
||||
await userEvent.click(unpublishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByTestId("handle-unpublish-button"));
|
||||
|
||||
expect(mockDeleteResultShareUrlAction).toHaveBeenCalledWith({ surveyId: mockSurvey.id });
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith("environments.surveys.results_unpublished_successfully");
|
||||
await waitFor(() => {
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showPublishModal: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("opens and closes ShareSurveyResults modal", async () => {
|
||||
mockGetResultShareUrlAction.mockResolvedValue({ data: null });
|
||||
render(<ResultsShareButton survey={mockSurvey} publicDomain={webAppUrl} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("dropdown-menu-trigger"));
|
||||
const publishButton = (await screen.findAllByTestId("dropdown-menu-item")).find((item) =>
|
||||
item.textContent?.includes("environments.surveys.summary.publish_to_web")
|
||||
);
|
||||
await userEvent.click(publishButton!);
|
||||
|
||||
expect(screen.getByTestId("share-survey-results-modal")).toBeInTheDocument();
|
||||
expect(mockShareSurveyResults).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
open: true,
|
||||
surveyUrl: "", // Initially empty as no key fetched yet for this flow
|
||||
showPublishModal: false, // Initially false
|
||||
})
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Close Modal"));
|
||||
expect(screen.queryByTestId("share-survey-results-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
deleteResultShareUrlAction,
|
||||
generateResultShareUrlAction,
|
||||
getResultShareUrlAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon, DownloadIcon, GlobeIcon, LinkIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { ShareSurveyResults } from "../(analysis)/summary/components/ShareSurveyResults";
|
||||
|
||||
interface ResultsShareButtonProps {
|
||||
survey: TSurvey;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ResultsShareButton = ({ survey, publicDomain }: ResultsShareButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [showResultsLinkModal, setShowResultsLinkModal] = useState(false);
|
||||
|
||||
const [showPublishModal, setShowPublishModal] = useState(false);
|
||||
const [surveyUrl, setSurveyUrl] = useState("");
|
||||
|
||||
const handlePublish = async () => {
|
||||
const resultShareKeyResponse = await generateResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareKeyResponse?.data) {
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareKeyResponse.data);
|
||||
setShowPublishModal(true);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(resultShareKeyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = () => {
|
||||
deleteResultShareUrlAction({ surveyId: survey.id }).then((deleteResultShareUrlResponse) => {
|
||||
if (deleteResultShareUrlResponse?.data) {
|
||||
toast.success(t("environments.surveys.results_unpublished_successfully"));
|
||||
setShowPublishModal(false);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(deleteResultShareUrlResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSharingKey = async () => {
|
||||
const resultShareUrlResponse = await getResultShareUrlAction({ surveyId: survey.id });
|
||||
if (resultShareUrlResponse?.data) {
|
||||
setSurveyUrl(publicDomain + "/share/" + resultShareUrlResponse.data);
|
||||
setShowPublishModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSharingKey();
|
||||
}, [survey.id, publicDomain]);
|
||||
|
||||
const copyUrlToClipboard = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const currentUrl = window.location.href;
|
||||
navigator.clipboard
|
||||
.writeText(currentUrl)
|
||||
.then(() => {
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t("environments.surveys.failed_to_copy_link_to_results"));
|
||||
});
|
||||
} else {
|
||||
toast.error(t("environments.surveys.failed_to_copy_url"));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className="focus:bg-muted cursor-pointer border border-slate-200 outline-none hover:border-slate-300">
|
||||
<div className="min-w-auto h-auto rounded-md border bg-white p-3 sm:flex sm:min-w-[7rem] sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">
|
||||
{t("environments.surveys.summary.share_results")}
|
||||
</span>
|
||||
<LinkIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
<DownloadIcon className="block h-4 sm:hidden" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{survey.resultShareKey ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
toast.success(t("environments.surveys.summary.link_to_public_results_copied"));
|
||||
}}
|
||||
icon={<CopyIcon className="ml-1.5 inline h-4 w-4" />}>
|
||||
<p className="text-slate-700">
|
||||
{t("environments.surveys.summary.copy_link_to_public_results")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
copyUrlToClipboard();
|
||||
}}
|
||||
icon={<CopyIcon className="ml-1.5 h-4 w-4" />}>
|
||||
<p className="flex items-center text-slate-700">{t("common.copy_link")}</p>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setShowResultsLinkModal(true);
|
||||
}}
|
||||
icon={<GlobeIcon className="ml-1.5 h-4 w-4" />}>
|
||||
<p className="flex items-center text-slate-700">
|
||||
{survey.resultShareKey
|
||||
? t("environments.surveys.summary.unpublish_from_web")
|
||||
: t("environments.surveys.summary.publish_to_web")}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{showResultsLinkModal && (
|
||||
<ShareSurveyResults
|
||||
open={showResultsLinkModal}
|
||||
setOpen={setShowResultsLinkModal}
|
||||
surveyUrl={surveyUrl}
|
||||
handlePublish={handlePublish}
|
||||
handleUnpublish={handleUnpublish}
|
||||
showPublishModal={showPublishModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-1
@@ -87,7 +87,6 @@ const baseSurvey: TSurvey = {
|
||||
isSingleUse: false,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
singleUse: null,
|
||||
verifyEmail: null,
|
||||
pin: null,
|
||||
|
||||
-1
@@ -78,7 +78,6 @@ const mockSurvey: TSurvey = {
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [
|
||||
{
|
||||
|
||||
@@ -68,7 +68,6 @@ describe("SurveyLayout", () => {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
showLanguageSwitch: false,
|
||||
recaptcha: null,
|
||||
languages: [],
|
||||
|
||||
@@ -165,7 +165,6 @@ export const mockSurvey: TSurvey = {
|
||||
isEncrypted: true,
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
showLanguageSwitch: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
|
||||
@@ -152,7 +152,6 @@ const mockSurvey = {
|
||||
environmentId: "env1",
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
} as unknown as TSurvey;
|
||||
|
||||
|
||||
@@ -89,7 +89,6 @@ const baseSurvey: TSurvey = {
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
|
||||
@@ -64,7 +64,6 @@ const baseSurvey: TSurvey = {
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
|
||||
@@ -108,7 +108,6 @@ const mockSurveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
@@ -37,7 +37,6 @@ const mockDeletedSurveyAppPrivateSegment = {
|
||||
type: "app",
|
||||
segment: { id: segmentId, isPrivate: true },
|
||||
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
||||
resultShareKey: "shareKey123",
|
||||
};
|
||||
|
||||
const mockDeletedSurveyLink = {
|
||||
@@ -46,7 +45,6 @@ const mockDeletedSurveyLink = {
|
||||
type: "link",
|
||||
segment: null,
|
||||
triggers: [],
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
describe("deleteSurvey", () => {
|
||||
|
||||
@@ -68,7 +68,6 @@ const mockSurvey: TSurvey = {
|
||||
triggers: [],
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
@@ -3582,7 +3582,6 @@ export const previewSurvey = (projectName: string, t: TFnType) => {
|
||||
isEncrypted: true,
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
showLanguageSwitch: false,
|
||||
|
||||
@@ -101,12 +101,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return true for share routes", () => {
|
||||
expect(isPublicDomainRoute("/share/abc123/summary")).toBe(true);
|
||||
expect(isPublicDomainRoute("/share/xyz789/responses")).toBe(true);
|
||||
expect(isPublicDomainRoute("/share/anything")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for admin-only routes", () => {
|
||||
expect(isPublicDomainRoute("/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/environments/123")).toBe(false);
|
||||
@@ -155,7 +149,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/share/abc/summary", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
// Static assets not tested - middleware doesn't run on them
|
||||
});
|
||||
@@ -181,7 +174,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/share/abc/summary", false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,11 +13,6 @@ const PUBLIC_ROUTES = {
|
||||
API_ROUTES: [
|
||||
/^\/api\/v[12]\/client\//, // /api/v1/client/** and /api/v2/client/**
|
||||
],
|
||||
|
||||
// Share routes
|
||||
SHARE_ROUTES: [
|
||||
/^\/share\//, // /share/** - shared survey results
|
||||
],
|
||||
} as const;
|
||||
|
||||
const COMMON_ROUTES = {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type Params = Promise<{
|
||||
sharingKey: string;
|
||||
}>;
|
||||
|
||||
interface ResponsesPageProps {
|
||||
params: Params;
|
||||
}
|
||||
|
||||
const Page = async (props: ResponsesPageProps) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
if (!surveyId) {
|
||||
return notFound();
|
||||
}
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
const environmentId = survey.environmentId;
|
||||
const [environment, project, tags] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getProjectByEnvironmentId(environmentId),
|
||||
getTagsByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="responses" />
|
||||
</PageHeader>
|
||||
<ResponsePage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
publicDomain={publicDomain}
|
||||
environmentTags={tags}
|
||||
responsesPerPage={RESPONSES_PER_PAGE}
|
||||
locale={locale}
|
||||
isReadOnly={true}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,144 +0,0 @@
|
||||
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock all dependencies to avoid server-side environment issues
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SHORT_URL_BASE: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
IS_FORMBRICKS_CLOUD: "0",
|
||||
NODE_ENV: "test",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SHORT_URL_BASE: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
RATE_LIMITING_DISABLED: "false",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
share: {
|
||||
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveyIdByResultShareKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Share Summary Page Rate Limiting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Rate Limiting Configuration", () => {
|
||||
test("should have correct rate limit config for share URLs", () => {
|
||||
expect(rateLimitConfigs.share.url).toEqual({
|
||||
interval: 60,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "share:url",
|
||||
});
|
||||
});
|
||||
|
||||
test("should apply rate limiting function correctly", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 60,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "share:url",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw rate limit error when limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Share Key Validation Flow", () => {
|
||||
test("should validate sharing key after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
||||
|
||||
// Simulate the flow: rate limit first, then validate sharing key
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
const surveyId = await getSurveyIdByResultShareKey("test-sharing-key-123");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("test-sharing-key-123");
|
||||
expect(surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
test("should handle invalid sharing keys after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
||||
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
const surveyId = await getSurveyIdByResultShareKey("invalid-key");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("invalid-key");
|
||||
expect(surveyId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should rate limit all requests regardless of sharing key validity", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
// Test with valid sharing key
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
await getSurveyIdByResultShareKey("valid-key");
|
||||
|
||||
// Test with invalid sharing key
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
await getSurveyIdByResultShareKey("invalid-key");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should not expose internal errors when rate limited", async () => {
|
||||
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
// Ensure no other operations are performed
|
||||
expect(getSurveyIdByResultShareKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,80 +0,0 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||
import { getSurveySummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
import { DEFAULT_LOCALE } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
type Params = Promise<{
|
||||
sharingKey: string;
|
||||
}>;
|
||||
|
||||
interface SummaryPageProps {
|
||||
params: Params;
|
||||
}
|
||||
|
||||
const Page = async (props: SummaryPageProps) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
if (!surveyId) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new Error(t("common.survey_not_found"));
|
||||
}
|
||||
|
||||
const environmentId = survey.environmentId;
|
||||
|
||||
const [environment, project] = await Promise.all([
|
||||
getEnvironment(environmentId),
|
||||
getProjectByEnvironmentId(environmentId),
|
||||
]);
|
||||
|
||||
if (!environment) {
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<PageContentWrapper className="w-full">
|
||||
<PageHeader pageTitle={survey.name}>
|
||||
<SurveyAnalysisNavigation survey={survey} environmentId={environment.id} activeId="summary" />
|
||||
</PageHeader>
|
||||
<SummaryPage
|
||||
environment={environment}
|
||||
survey={survey}
|
||||
surveyId={survey.id}
|
||||
publicDomain={publicDomain}
|
||||
isReadOnly={true}
|
||||
locale={DEFAULT_LOCALE}
|
||||
initialSurveySummary={initialSurveySummary}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,80 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { getResponseCountBySurveyId, getResponseFilteringValues, getResponses } from "@/lib/response/service";
|
||||
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { getSurveySummary } from "../../(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary";
|
||||
|
||||
const ZGetResponsesBySurveySharingKeyAction = z.object({
|
||||
sharingKey: z.string(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getResponsesBySurveySharingKeyAction = actionClient
|
||||
.schema(ZGetResponsesBySurveySharingKeyAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const responses = await getResponses(
|
||||
surveyId,
|
||||
parsedInput.limit,
|
||||
parsedInput.offset,
|
||||
parsedInput.filterCriteria
|
||||
);
|
||||
return responses;
|
||||
});
|
||||
|
||||
const ZGetSummaryBySurveySharingKeyAction = z.object({
|
||||
sharingKey: z.string(),
|
||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getSummaryBySurveySharingKeyAction = actionClient
|
||||
.schema(ZGetSummaryBySurveySharingKeyAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return getSurveySummary(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetResponseCountBySurveySharingKeyAction = z.object({
|
||||
sharingKey: z.string(),
|
||||
filterCriteria: ZResponseFilterCriteria.optional(),
|
||||
});
|
||||
|
||||
export const getResponseCountBySurveySharingKeyAction = actionClient
|
||||
.schema(ZGetResponseCountBySurveySharingKeyAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return getResponseCountBySurveyId(surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetSurveyFilterDataBySurveySharingKeyAction = z.object({
|
||||
sharingKey: z.string(),
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveyFilterDataBySurveySharingKeyAction = actionClient
|
||||
.schema(ZGetSurveyFilterDataBySurveySharingKeyAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const surveyId = await getSurveyIdByResultShareKey(parsedInput.sharingKey);
|
||||
if (!surveyId) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([
|
||||
getTagsByEnvironmentId(parsedInput.environmentId),
|
||||
getResponseFilteringValues(surveyId),
|
||||
]);
|
||||
|
||||
return { environmentTags: tags, attributes, meta, hiddenFields };
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
||||
import { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const EnvironmentLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="flex-1 bg-slate-50">
|
||||
<ResponseFilterProvider>{children}</ResponseFilterProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentLayout;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import Link from "next/link";
|
||||
|
||||
const NotFound = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center py-16 text-center">
|
||||
<p className="text-sm font-semibold text-zinc-900 dark:text-white">404</p>
|
||||
<h1 className="mt-2 text-2xl font-bold text-zinc-900 dark:text-white">{t("share.page_not_found")}</h1>
|
||||
<p className="mt-2 text-base text-zinc-600 dark:text-zinc-400">
|
||||
{t("share.page_not_found_description")}
|
||||
</p>
|
||||
<Link href={"/"}>
|
||||
<Button className="mt-8">{t("share.back_to_home")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,47 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the redirect function
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the page component
|
||||
const PageComponent = (await import("./page")).default;
|
||||
|
||||
describe("Share Redirect Page", () => {
|
||||
test("should redirect to summary page without rate limiting", async () => {
|
||||
const mockParams = Promise.resolve({ sharingKey: "test-sharing-key-123" });
|
||||
|
||||
await PageComponent({ params: mockParams });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/share/test-sharing-key-123/summary");
|
||||
});
|
||||
|
||||
test("should handle different sharing keys", async () => {
|
||||
const testCases = ["abc123", "survey-key-456", "long-sharing-key-with-dashes-789"];
|
||||
|
||||
for (const sharingKey of testCases) {
|
||||
vi.clearAllMocks();
|
||||
const mockParams = Promise.resolve({ sharingKey });
|
||||
|
||||
await PageComponent({ params: mockParams });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith(`/share/${sharingKey}/summary`);
|
||||
}
|
||||
});
|
||||
|
||||
test("should be lightweight and not perform any rate limiting", async () => {
|
||||
// This test ensures the page doesn't import or use rate limiting
|
||||
const mockParams = Promise.resolve({ sharingKey: "test-key" });
|
||||
|
||||
// Measure execution time to ensure it's very fast (< 10ms)
|
||||
const startTime = performance.now();
|
||||
await PageComponent({ params: mockParams });
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
expect(executionTime).toBeLessThan(10); // Should be very fast since it's just a redirect
|
||||
expect(redirect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type Params = Promise<{
|
||||
sharingKey: string;
|
||||
}>;
|
||||
|
||||
const Page = async (props: { params: Params }) => {
|
||||
const params = await props.params;
|
||||
return redirect(`/share/${params.sharingKey}/summary`);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -316,7 +316,6 @@ export const mockSurvey: TSurvey = {
|
||||
isEncrypted: true,
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
triggers: [],
|
||||
languages: mockSurveyLanguages,
|
||||
segment: null,
|
||||
|
||||
@@ -520,7 +520,6 @@ export const mockSurvey: TSurvey = {
|
||||
isEncrypted: true,
|
||||
},
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
triggers: [],
|
||||
languages: [],
|
||||
segment: [],
|
||||
|
||||
@@ -258,7 +258,6 @@ describe("Response Processing", () => {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
|
||||
@@ -259,7 +259,6 @@ export const mockSyncSurveyOutput: SurveyMock = {
|
||||
pin: null,
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: null,
|
||||
languages: mockSurveyLanguages,
|
||||
...baseSurveyProperties,
|
||||
@@ -284,7 +283,6 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
pin: null,
|
||||
segment: null,
|
||||
segmentId: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: null,
|
||||
languages: mockSurveyLanguages,
|
||||
followUps: [],
|
||||
@@ -314,7 +312,6 @@ export const updateSurveyInput: TSurvey = {
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
recaptcha: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: null,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
createSurvey,
|
||||
getSurvey,
|
||||
getSurveyCount,
|
||||
getSurveyIdByResultShareKey,
|
||||
getSurveys,
|
||||
getSurveysByActionClassId,
|
||||
getSurveysBySegmentId,
|
||||
@@ -748,52 +747,6 @@ describe("Tests for createSurvey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for getSurveyIdByResultShareKey", () => {
|
||||
const mockResultShareKey = "share-key-123";
|
||||
|
||||
describe("Happy Path", () => {
|
||||
test("returns survey ID when found", async () => {
|
||||
prisma.survey.findFirst.mockResolvedValueOnce({
|
||||
id: mockId,
|
||||
} as Survey);
|
||||
|
||||
const result = await getSurveyIdByResultShareKey(mockResultShareKey);
|
||||
|
||||
expect(prisma.survey.findFirst).toHaveBeenCalledWith({
|
||||
where: { resultShareKey: mockResultShareKey },
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result).toBe(mockId);
|
||||
});
|
||||
|
||||
test("returns null when survey not found", async () => {
|
||||
prisma.survey.findFirst.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getSurveyIdByResultShareKey(mockResultShareKey);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws DatabaseError on Prisma error", async () => {
|
||||
const mockError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
});
|
||||
prisma.survey.findFirst.mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(getSurveyIdByResultShareKey(mockResultShareKey)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error on unexpected error", async () => {
|
||||
prisma.survey.findFirst.mockRejectedValueOnce(new Error("Unexpected error"));
|
||||
|
||||
await expect(getSurveyIdByResultShareKey(mockResultShareKey)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tests for loadNewSegmentInSurvey", () => {
|
||||
const mockSurveyId = mockId;
|
||||
const mockNewSegmentId = "segment456";
|
||||
|
||||
@@ -58,7 +58,6 @@ export const selectSurvey = {
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
resultShareKey: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
languages: {
|
||||
@@ -705,34 +704,6 @@ export const createSurvey = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getSurveyIdByResultShareKey = reactCache(
|
||||
async (resultShareKey: string): Promise<string | null> => {
|
||||
try {
|
||||
const survey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
resultShareKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!survey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return survey.id;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting survey id by result share key");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: string): Promise<TSurvey> => {
|
||||
validateInputs([surveyId, ZId], [newSegmentId, ZId]);
|
||||
try {
|
||||
|
||||
@@ -98,7 +98,6 @@ const survey: TSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
productOverwrites: null,
|
||||
resultShareKey: null,
|
||||
pin: null,
|
||||
verifyEmail: null,
|
||||
attributeFilters: [],
|
||||
|
||||
@@ -57,7 +57,6 @@ describe("rateLimitConfigs", () => {
|
||||
expect(rateLimitConfigs).toHaveProperty("auth");
|
||||
expect(rateLimitConfigs).toHaveProperty("api");
|
||||
expect(rateLimitConfigs).toHaveProperty("actions");
|
||||
expect(rateLimitConfigs).toHaveProperty("share");
|
||||
});
|
||||
|
||||
test("should have all auth configurations", () => {
|
||||
@@ -74,11 +73,6 @@ describe("rateLimitConfigs", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
|
||||
});
|
||||
|
||||
test("should have all share configurations", () => {
|
||||
const shareConfigs = Object.keys(rateLimitConfigs.share);
|
||||
expect(shareConfigs).toEqual(["url"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod Validation", () => {
|
||||
@@ -87,7 +81,6 @@ describe("rateLimitConfigs", () => {
|
||||
...Object.values(rateLimitConfigs.auth),
|
||||
...Object.values(rateLimitConfigs.api),
|
||||
...Object.values(rateLimitConfigs.actions),
|
||||
...Object.values(rateLimitConfigs.share),
|
||||
];
|
||||
|
||||
allConfigs.forEach((config) => {
|
||||
@@ -104,7 +97,6 @@ describe("rateLimitConfigs", () => {
|
||||
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.share).forEach((config) => allNamespaces.push(config.namespace));
|
||||
|
||||
const uniqueNamespaces = new Set(allNamespaces);
|
||||
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
|
||||
@@ -138,7 +130,6 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
|
||||
];
|
||||
|
||||
const testAllowedRequest = async (config: any, identifier: string) => {
|
||||
|
||||
@@ -19,9 +19,4 @@ export const rateLimitConfigs = {
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
},
|
||||
|
||||
// Share pages - moderate limits for public access
|
||||
share: {
|
||||
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" }, // 30 per minute
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,7 +32,6 @@ const SENSITIVE_KEYS = [
|
||||
"pin",
|
||||
"image",
|
||||
"stripeCustomerId",
|
||||
"resultShareKey",
|
||||
"fileName",
|
||||
];
|
||||
|
||||
|
||||
-3
@@ -74,7 +74,6 @@ const mockSurveys: TSurvey[] = [
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
@@ -104,7 +103,6 @@ const mockSurveys: TSurvey[] = [
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
@@ -135,7 +133,6 @@ const mockSurveys: TSurvey[] = [
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
surveyClosedMessage: null,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
|
||||
@@ -57,7 +57,6 @@ const mockSurvey = {
|
||||
languages: [],
|
||||
triggers: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
@@ -293,7 +293,6 @@ const mockSurvey = {
|
||||
enabled: false,
|
||||
isEncrypted: false,
|
||||
},
|
||||
resultShareKey: null,
|
||||
endings: [
|
||||
{
|
||||
id: "ending_1",
|
||||
|
||||
@@ -41,7 +41,6 @@ vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
type: true,
|
||||
status: true,
|
||||
environmentId: true,
|
||||
resultShareKey: true,
|
||||
segment: true,
|
||||
},
|
||||
}));
|
||||
@@ -90,7 +89,6 @@ describe("survey module", () => {
|
||||
id: "survey-123",
|
||||
environmentId,
|
||||
type: "app",
|
||||
resultShareKey: "key-123",
|
||||
segment: {
|
||||
surveys: [{ id: "survey-123" }],
|
||||
},
|
||||
|
||||
@@ -107,7 +107,6 @@ const mockSurvey: TSurvey = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
segment: null,
|
||||
closeOnDate: null,
|
||||
|
||||
@@ -31,7 +31,6 @@ const mockSurvey: TSurvey = {
|
||||
updatedAt: new Date(),
|
||||
createdBy: null,
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
closeOnDate: null,
|
||||
runOnDate: null,
|
||||
|
||||
@@ -69,7 +69,6 @@ const baseSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
|
||||
@@ -141,7 +141,6 @@ const mockSurvey = {
|
||||
displayPercentage: null,
|
||||
inlineTriggers: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
redirectUrl: null,
|
||||
|
||||
@@ -61,7 +61,6 @@ const baseSurvey = {
|
||||
hiddenFields: { enabled: true },
|
||||
variables: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -138,7 +138,6 @@ const baseSurvey = {
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
displayLimit: null,
|
||||
resultShareKey: null,
|
||||
inlineTriggers: null,
|
||||
pinResponses: false,
|
||||
productOverwrites: null,
|
||||
|
||||
@@ -235,7 +235,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||
variables: [],
|
||||
|
||||
@@ -94,7 +94,6 @@ const baseSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -91,7 +91,6 @@ const mockSurvey = {
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
||||
@@ -124,7 +124,6 @@ const baseSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
runOnDate: null,
|
||||
|
||||
@@ -53,7 +53,6 @@ const mockSurvey = {
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -36,7 +36,6 @@ const mockSurvey = {
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
|
||||
@@ -99,7 +99,6 @@ const mockSurveyAppBase = {
|
||||
languages: [],
|
||||
styling: null,
|
||||
variables: [],
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
singleUse: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
@@ -102,7 +102,6 @@ describe("Survey Editor Library Tests", () => {
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [
|
||||
{
|
||||
|
||||
@@ -36,7 +36,6 @@ export const selectSurvey = {
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
resultShareKey: true,
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
isBackButtonHidden: true,
|
||||
|
||||
@@ -95,7 +95,6 @@ describe("data", () => {
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
isBackButtonHidden: false,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
@@ -222,7 +221,6 @@ describe("data", () => {
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
isBackButtonHidden: false,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
|
||||
@@ -48,7 +48,6 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
resultShareKey: true,
|
||||
isBackButtonHidden: true,
|
||||
|
||||
// Single use configuration
|
||||
|
||||
@@ -361,7 +361,6 @@ describe("deleteSurvey", () => {
|
||||
environmentId,
|
||||
segment: null,
|
||||
type: "web" as any,
|
||||
resultShareKey: "sharekey1",
|
||||
triggers: [{ actionClass: { id: "action_1" } }],
|
||||
};
|
||||
|
||||
@@ -467,7 +466,6 @@ describe("copySurveyToOtherEnvironment", () => {
|
||||
{ actionClass: { id: "new_ac2", name: "No-Code Action", environmentId: targetEnvironmentId } },
|
||||
],
|
||||
languages: [{ language: { code: "en" } }],
|
||||
resultShareKey: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -193,7 +193,6 @@ export const deleteSurvey = async (surveyId: string): Promise<boolean> => {
|
||||
},
|
||||
},
|
||||
type: true,
|
||||
resultShareKey: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
@@ -558,7 +557,6 @@ export const copySurveyToOtherEnvironment = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
resultShareKey: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ export const getMinimalSurvey = (t: TFnType): TSurvey => ({
|
||||
recaptcha: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
languages: [],
|
||||
showLanguageSwitch: false,
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
"lucide-react": "0.507.0",
|
||||
"markdown-it": "14.1.0",
|
||||
"mime-types": "3.0.1",
|
||||
"nanoid": "5.1.5",
|
||||
"next": "15.3.3",
|
||||
"next-auth": "4.24.11",
|
||||
"next-safe-action": "7.10.8",
|
||||
|
||||
@@ -73,7 +73,6 @@ export default defineConfig({
|
||||
"modules/setup/**/intro/**", // Setup intro pages
|
||||
"modules/setup/**/signup/**", // Setup signup pages
|
||||
"modules/setup/**/layout.tsx", // Setup layouts
|
||||
"app/share/**", // Share functionality
|
||||
"lib/shortUrl/**", // Short URL functionality
|
||||
"app/[shortUrlId]", // Short URL pages
|
||||
"modules/ee/contacts/components/**", // Contact components
|
||||
|
||||
@@ -6109,7 +6109,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segment": null,
|
||||
"showLanguageSwitch": null,
|
||||
@@ -6293,7 +6292,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segmentId": null,
|
||||
"showLanguageSwitch": null,
|
||||
@@ -6414,7 +6412,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segment": null,
|
||||
"showLanguageSwitch": null,
|
||||
@@ -6683,7 +6680,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segment": null,
|
||||
"showLanguageSwitch": null,
|
||||
@@ -6939,7 +6935,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segment": null,
|
||||
"showLanguageSwitch": null,
|
||||
@@ -7145,7 +7140,6 @@
|
||||
],
|
||||
"recontactDays": null,
|
||||
"redirectUrl": null,
|
||||
"resultShareKey": null,
|
||||
"runOnDate": null,
|
||||
"segment": null,
|
||||
"showLanguageSwitch": null,
|
||||
|
||||
@@ -4037,11 +4037,6 @@ components:
|
||||
- boolean
|
||||
- "null"
|
||||
description: Whether to display the progress bar
|
||||
resultShareKey:
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
description: The result share key of the survey
|
||||
pin:
|
||||
type:
|
||||
- string
|
||||
@@ -4506,7 +4501,6 @@ components:
|
||||
- showThankYouMessage
|
||||
- welcomeCard
|
||||
- displayProgressBar
|
||||
- resultShareKey
|
||||
- pin
|
||||
- createdBy
|
||||
- environmentId
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
"xm-and-surveys/surveys/general-features/multi-language-surveys",
|
||||
"xm-and-surveys/surveys/general-features/partial-submissions",
|
||||
"xm-and-surveys/surveys/general-features/recall",
|
||||
"xm-and-surveys/surveys/general-features/shareable-dashboards",
|
||||
"xm-and-surveys/surveys/general-features/schedule-start-end-dates",
|
||||
"xm-and-surveys/surveys/general-features/metadata",
|
||||
"xm-and-surveys/surveys/general-features/variables",
|
||||
@@ -450,11 +449,6 @@
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/recall"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/general-features/shareable-dashboards",
|
||||
"permanent": true,
|
||||
"source": "/docs/link-surveys/global/shareable-dashboards"
|
||||
},
|
||||
{
|
||||
"destination": "/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
|
||||
"permanent": true,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
title: "Shareable Dashboards"
|
||||
description: "Create public, shareable versions of your survey results dashboards. This feature enables you to easily share survey results with stakeholders, team members, or the public without granting access to your Formbricks account."
|
||||
icon: "table-columns"
|
||||
---
|
||||
|
||||
## How To Publish Survey Results
|
||||
|
||||
1. **Go to survey summary**: Choose the survey for which you want to create a shareable dashboard and go to its summary page.
|
||||
|
||||
2. **Share results**: Click the "Share results" and then "Publish to web".
|
||||
|
||||

|
||||
|
||||
1. **Confirm**: Click "Publish to public web" (it's public).
|
||||
|
||||

|
||||
|
||||
|
||||
1. **Share link**: Formbricks has generated a unique URL for your public dashboard. Share it around.
|
||||
|
||||

|
||||
|
||||
<Note>Whoever has access to the link can access the survey results.</Note>
|
||||
|
||||
## How To Unpublish Survey Results
|
||||
|
||||
Unpublish is very simple: Go to "Share results" -> "Unpublish from web" -> "Unpublish".
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Read-only access**: Viewers can see survey results but cannot modify data or settings.
|
||||
|
||||
- **Real-time updates**: The shared dashboard reflects current survey data in real-time.
|
||||
|
||||
- **Filters included**: Visitors can access all filters to dissect the data.
|
||||
|
||||
- **Revocable access**: You can disable the shared link at any time to restrict access.
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Share results with clients or stakeholders
|
||||
|
||||
- Publish survey findings to your website or blog
|
||||
|
||||
- Collaborate with team members without sharing account credentials
|
||||
|
||||
- Create transparency by making certain survey results public
|
||||
|
||||
Shareable dashboards provide a simple yet powerful way to disseminate survey insights while maintaining control over your Formbricks account and data.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user