mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-27 08:50:38 -06:00
Compare commits
20 Commits
fix-requir
...
fix-verify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfd1bae70 | ||
|
|
09404539d3 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 | ||
|
|
f043314537 | ||
|
|
2ce842dd8d | ||
|
|
43b43839c5 | ||
|
|
8b6e3fec37 | ||
|
|
31bcf98779 |
@@ -194,9 +194,6 @@ REDIS_URL=redis://localhost:6379
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
type: bug
|
||||
projects: "formbricks/8"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
@@ -76,12 +76,9 @@ jobs:
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
cd ./apps/web
|
||||
npm version $VERSION --no-git-tag-version
|
||||
echo "Updated version to: $(npm pkg get version)"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TProject } from "@formbricks/types/project";
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
@@ -35,6 +36,7 @@ export const EnvironmentContextWrapper = ({
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
TUserPersonalInfoUpdateInput,
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/display/service", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
@@ -178,6 +183,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
@@ -206,6 +212,8 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: mockPublicDomain,
|
||||
responseCount: 10,
|
||||
displayCount: 5,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -40,6 +41,7 @@ const Page = async (props) => {
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -56,6 +58,7 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -202,6 +203,61 @@ export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
)
|
||||
);
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
@@ -33,6 +33,18 @@ vi.mock("@tolgee/react", () => ({
|
||||
if (key === "environments.surveys.edit.caution_edit_duplicate") {
|
||||
return "Duplicate & Edit";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey") {
|
||||
return "Reset survey";
|
||||
}
|
||||
if (key === "environments.surveys.summary.delete_all_existing_responses_and_displays") {
|
||||
return "Delete all existing responses and displays";
|
||||
}
|
||||
if (key === "environments.surveys.summary.reset_survey_warning") {
|
||||
return "Resetting a survey removes all responses and metadata of this survey. This cannot be undone.";
|
||||
}
|
||||
if (key === "environments.surveys.summary.survey_reset_successfully") {
|
||||
return "Survey reset successfully! 5 responses and 3 displays were deleted.";
|
||||
}
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
@@ -40,12 +52,14 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockRefresh = vi.fn();
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
refresh: mockRefresh,
|
||||
}),
|
||||
usePathname: () => mockPathname,
|
||||
useSearchParams: () => mockSearchParams,
|
||||
@@ -69,6 +83,10 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../actions", () => ({
|
||||
resetSurveyAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
@@ -147,6 +165,34 @@ vi.mock("@/modules/ui/components/badge", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/confirmation-modal", () => ({
|
||||
ConfirmationModal: ({
|
||||
open,
|
||||
setOpen,
|
||||
title,
|
||||
text,
|
||||
buttonText,
|
||||
onConfirm,
|
||||
buttonVariant,
|
||||
buttonLoading,
|
||||
}: any) => (
|
||||
<div
|
||||
data-testid="confirmation-modal"
|
||||
data-open={open}
|
||||
data-loading={buttonLoading}
|
||||
data-variant={buttonVariant}>
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
<div data-testid="modal-text">{text}</div>
|
||||
<button type="button" onClick={onConfirm} data-testid="confirm-button">
|
||||
{buttonText}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)} data-testid="cancel-button">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, className }: any) => (
|
||||
<button type="button" data-testid="button" onClick={onClick} className={className}>
|
||||
@@ -178,9 +224,17 @@ vi.mock("@/modules/ui/components/iconbar", () => ({
|
||||
vi.mock("lucide-react", () => ({
|
||||
BellRing: () => <svg data-testid="bell-ring-icon" />,
|
||||
Eye: () => <svg data-testid="eye-icon" />,
|
||||
ListRestart: () => <svg data-testid="list-restart-icon" />,
|
||||
SquarePenIcon: () => <svg data-testid="square-pen-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
|
||||
useEnvironment: vi.fn(() => ({
|
||||
organizationId: "test-organization-id",
|
||||
project: { id: "test-project-id" },
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
@@ -270,6 +324,7 @@ const defaultProps = {
|
||||
user: mockUser,
|
||||
publicDomain: "https://example.com",
|
||||
responseCount: 0,
|
||||
displayCount: 0,
|
||||
segments: mockSegments,
|
||||
isContactsEnabled: true,
|
||||
isFormbricksCloud: false,
|
||||
@@ -286,19 +341,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders share survey button", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success message component", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders survey status dropdown when app setup is completed", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
@@ -310,7 +365,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("renders icon bar with correct actions", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toBeInTheDocument(); // Bell ring
|
||||
@@ -334,7 +389,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -344,7 +399,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal when share param is true", () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "start");
|
||||
@@ -352,7 +407,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("navigates to edit when edit button is clicked and no responses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
@@ -363,14 +418,15 @@ describe("SurveyAnalysisCTA", () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
// With responseCount > 0, the edit button should be at icon-bar-action-2 (after reset button)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
});
|
||||
|
||||
test("navigates to notifications when bell icon is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-0"));
|
||||
|
||||
@@ -391,7 +447,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not show icon bar actions when read-only", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isReadOnly={true} />);
|
||||
|
||||
const iconBar = screen.getByTestId("icon-bar");
|
||||
expect(iconBar).toBeInTheDocument();
|
||||
@@ -402,7 +458,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("handles modal close correctly", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Verify modal is open initially
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -429,13 +485,13 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("does not show status dropdown when app setup is not completed", () => {
|
||||
const environmentWithoutAppSetup = { ...mockEnvironment, appSetupCompleted: false };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} environment={environmentWithoutAppSetup} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} environment={environmentWithoutAppSetup} />);
|
||||
|
||||
expect(screen.queryByTestId("survey-status-dropdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly with all props", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
expect(screen.getByTestId("icon-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Share survey")).toBeInTheDocument();
|
||||
@@ -579,8 +635,8 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
// Click edit button to open dialog (should be icon-bar-action-2 with responses)
|
||||
await user.click(screen.getByTestId("icon-bar-action-2"));
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
@@ -647,7 +703,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal with correct modal view when share button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -669,24 +725,28 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("does not render share modal when user is null", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
|
||||
const { rerender } = render(
|
||||
<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={true} />
|
||||
);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} displayCount={0} isContactsEnabled={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -701,7 +761,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("handles modal state changes correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Open modal via share button
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
@@ -714,7 +774,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
|
||||
test("opens share modal via share button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
@@ -726,7 +786,7 @@ describe("SurveyAnalysisCTA", () => {
|
||||
test("closes share modal and updates modal state", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Modal should be open initially due to share param
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
@@ -738,19 +798,19 @@ describe("SurveyAnalysisCTA", () => {
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} responseCount={0} />);
|
||||
|
||||
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly app survey", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={0} />);
|
||||
|
||||
// Should show bell (notifications) and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
@@ -766,4 +826,236 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
// Reset Survey Feature Tests
|
||||
test("shows reset survey button when responses exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows reset survey button when displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} displayCount={3} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides reset survey button when no responses or displays exist", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} displayCount={0} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeUndefined();
|
||||
});
|
||||
|
||||
test("hides reset survey button for read-only users", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} isReadOnly={true} responseCount={5} displayCount={3} />);
|
||||
|
||||
// For read-only users, there should be no icon bar actions
|
||||
expect(screen.queryAllByTestId(/icon-bar-action-/)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("opens reset confirmation modal when reset button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("modal-title")).toHaveTextContent("Delete all existing responses and displays");
|
||||
expect(screen.getByTestId("modal-text")).toHaveTextContent(
|
||||
"Resetting a survey removes all responses and metadata of this survey. This cannot be undone."
|
||||
);
|
||||
});
|
||||
|
||||
test("executes reset survey action when confirmed", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockResetSurveyAction).toHaveBeenCalledWith({
|
||||
surveyId: "test-survey-id",
|
||||
organizationId: "test-organization-id",
|
||||
projectId: "test-project-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"Survey reset successfully! 5 responses and 3 displays were deleted."
|
||||
);
|
||||
});
|
||||
|
||||
test("handles reset survey action error", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Reset failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("shows loading state during reset operation", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockResetSurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes reset modal after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Confirm reset - wait for the action to complete
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
// Wait for the action to complete and the modal to close
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
});
|
||||
|
||||
test("cancels reset operation when cancel button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Cancel reset
|
||||
await user.click(screen.getByTestId("cancel-button"));
|
||||
|
||||
// Modal should be closed
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("shows destructive button variant for reset confirmation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
expect(screen.getByTestId("confirmation-modal")).toHaveAttribute("data-variant", "destructive");
|
||||
});
|
||||
|
||||
test("refreshes page after successful reset", async () => {
|
||||
const mockResetSurveyAction = vi.mocked(await import("../actions")).resetSurveyAction;
|
||||
|
||||
mockResetSurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Open reset modal
|
||||
const iconActions = screen.getAllByTestId(/icon-bar-action-/);
|
||||
const resetButton = iconActions.find((button) => button.getAttribute("title") === "Reset survey");
|
||||
expect(resetButton).toBeDefined();
|
||||
await user.click(resetButton!);
|
||||
|
||||
// Confirm reset
|
||||
await user.click(screen.getByTestId("confirm-button"));
|
||||
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
@@ -9,9 +10,10 @@ import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -19,6 +21,7 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
@@ -27,6 +30,7 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
@@ -44,6 +48,7 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
@@ -57,7 +62,10 @@ export const SurveyAnalysisCTA = ({
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const { organizationId, project } = useEnvironment();
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
@@ -118,6 +126,29 @@ export const SurveyAnalysisCTA = ({
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const handleResetSurvey = async () => {
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("environments.surveys.summary.survey_reset_successfully", {
|
||||
responseCount: result.data.deletedResponsesCount,
|
||||
displayCount: result.data.deletedDisplaysCount,
|
||||
})
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setIsResetModalOpen(false);
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: BellRing,
|
||||
@@ -134,6 +165,12 @@ export const SurveyAnalysisCTA = ({
|
||||
},
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
@@ -202,6 +239,17 @@ export const SurveyAnalysisCTA = ({
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
buttonLoading={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
@@ -21,10 +22,12 @@ export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: Disabl
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
|
||||
@@ -43,6 +43,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
publicDomain={publicDomain}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={user.locale}
|
||||
enforceSurveyUrlWidth
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Deletes all responses and displays for a survey", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return the results directly
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("Handles case with no responses or displays to delete", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return zero counts
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 0,
|
||||
deletedDisplaysCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
surveyId: string
|
||||
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
|
||||
try {
|
||||
// Delete all responses for this survey
|
||||
|
||||
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
|
||||
prisma.response.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
prisma.display.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedResponsesCount: deletedResponsesCount.count,
|
||||
deletedDisplaysCount: deletedDisplaysCount.count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -281,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: t(filterRange)}
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -296,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -344,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -354,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -371,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -380,7 +380,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -89,8 +91,9 @@ const questionIcons = {
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className="ml-3 truncate text-sm text-slate-600">
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,11 @@ import {
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { TTemplate, TTemplateRole } from "@formbricks/types/templates";
|
||||
|
||||
const defaultButtonLabel = "common.next";
|
||||
const defaultBackButtonLabel = "common.back";
|
||||
const getDefaultButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.next"), []);
|
||||
|
||||
const getDefaultBackButtonLabel = (label: string | undefined, t: TFnType) =>
|
||||
createI18nString(label || t("common.back"), []);
|
||||
|
||||
export const buildMultipleChoiceQuestion = ({
|
||||
id,
|
||||
@@ -63,8 +66,8 @@ export const buildMultipleChoiceQuestion = ({
|
||||
const id = containsOther && isLastIndex ? "other" : choiceIds ? choiceIds[index] : createId();
|
||||
return { id, label: createI18nString(choice, []) };
|
||||
}),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
shuffleOption: shuffleOption || "none",
|
||||
required: required ?? false,
|
||||
logic,
|
||||
@@ -103,8 +106,8 @@ export const buildOpenTextQuestion = ({
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
placeholder: placeholder ? createI18nString(placeholder, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
longAnswer,
|
||||
logic,
|
||||
@@ -151,8 +154,8 @@ export const buildRatingQuestion = ({
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -192,8 +195,8 @@ export const buildNPSQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
@@ -228,8 +231,8 @@ export const buildConsentQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
required: required ?? false,
|
||||
label: createI18nString(label, []),
|
||||
logic,
|
||||
@@ -266,8 +269,8 @@ export const buildCTAQuestion = ({
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: html ? createI18nString(html, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
buttonLabel: createI18nString(buttonLabel || t(defaultButtonLabel), []),
|
||||
backButtonLabel: createI18nString(backButtonLabel || t(defaultBackButtonLabel), []),
|
||||
buttonLabel: getDefaultButtonLabel(buttonLabel, t),
|
||||
backButtonLabel: getDefaultBackButtonLabel(backButtonLabel, t),
|
||||
dismissButtonLabel: dismissButtonLabel ? createI18nString(dismissButtonLabel, []) : undefined,
|
||||
required: required ?? false,
|
||||
buttonExternal,
|
||||
|
||||
@@ -175,7 +175,6 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const REDIS_HTTP_URL = env.REDIS_HTTP_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const UNKEY_ROOT_KEY = env.UNKEY_ROOT_KEY;
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -116,7 +116,7 @@ export const env = createEnv({
|
||||
VERCEL_URL: z.string().optional(),
|
||||
WEBAPP_URL: z.string().url().optional(),
|
||||
UNSPLASH_ACCESS_KEY: z.string().optional(),
|
||||
UNKEY_ROOT_KEY: z.string().optional(),
|
||||
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
|
||||
PROMETHEUS_EXPORTER_PORT: z.string().optional(),
|
||||
PROMETHEUS_ENABLED: z.enum(["1", "0"]).optional(),
|
||||
@@ -218,7 +218,6 @@ export const env = createEnv({
|
||||
VERCEL_URL: process.env.VERCEL_URL,
|
||||
WEBAPP_URL: process.env.WEBAPP_URL,
|
||||
UNSPLASH_ACCESS_KEY: process.env.UNSPLASH_ACCESS_KEY,
|
||||
UNKEY_ROOT_KEY: process.env.UNKEY_ROOT_KEY,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
PROMETHEUS_ENABLED: process.env.PROMETHEUS_ENABLED,
|
||||
PROMETHEUS_EXPORTER_PORT: process.env.PROMETHEUS_EXPORTER_PORT,
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "Limits erreicht",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & E-Mail",
|
||||
"link_copied": "Link in die Zwischenablage kopiert!",
|
||||
"link_survey": "Link-Umfrage",
|
||||
"link_surveys": "Umfragen verknüpfen",
|
||||
"load_more": "Mehr laden",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "Datenschutz",
|
||||
"product_manager": "Produktmanager",
|
||||
"profile": "Profil",
|
||||
"project": "Projekt",
|
||||
"project_configuration": "Projektkonfiguration",
|
||||
"project_id": "Projekt-ID",
|
||||
"project_name": "Projektname",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "Website & App Verbindung",
|
||||
"website_app_survey": "Website- & App-Umfrage",
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weekly_summary": "Wöchentliche Zusammenfassung",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "Dein Kollege",
|
||||
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
|
||||
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
|
||||
"live_survey_notification_completed": "Abgeschlossen",
|
||||
"live_survey_notification_draft": "Entwurf",
|
||||
"live_survey_notification_in_progress": "In Bearbeitung",
|
||||
"live_survey_notification_no_new_response": "Diese Woche keine neue Antwort erhalten \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Noch keine Antworten!",
|
||||
"live_survey_notification_paused": "Pausiert",
|
||||
"live_survey_notification_scheduled": "Geplant",
|
||||
"live_survey_notification_view_more_responses": "Zeige {responseCount} weitere Antworten",
|
||||
"live_survey_notification_view_previous_responses": "Vorherige Antworten anzeigen",
|
||||
"live_survey_notification_view_response": "Antwort anzeigen",
|
||||
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
|
||||
"notification_footer_all_the_best": "Alles Gute,",
|
||||
"notification_footer_in_your_settings": "in deinen Einstellungen \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "Bitte ausstellen",
|
||||
"notification_footer_the_formbricks_team": "Dein Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Um wöchentliche Updates zu stoppen,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Wöchentlicher Bericht für",
|
||||
"notification_insight_completed": "Abgeschlossen",
|
||||
"notification_insight_completion_rate": "Completion Rate %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Antworten",
|
||||
"notification_insight_surveys": "Umfragen",
|
||||
"password_changed_email_heading": "Passwort geändert",
|
||||
"password_changed_email_text": "Dein Passwort wurde erfolgreich geändert.",
|
||||
"password_reset_notify_email_subject": "Dein Formbricks-Passwort wurde geändert",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "E-Mail bestätigen",
|
||||
"verification_new_email_subject": "E-Mail-Änderungsbestätigung",
|
||||
"verification_security_notice": "Wenn du diese E-Mail-Änderung nicht angefordert hast, ignoriere bitte diese E-Mail oder kontaktiere sofort den Support.",
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Wähle einen 15-minütigen Termin im Kalender unseres Gründers aus.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Lass keine Woche vergehen, ohne etwas über deine Nutzer zu lernen:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Brauchst Du Hilfe, die richtige Umfrage für dein Produkt zu finden?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "oder antworte auf diese E-Mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Neue Umfrage einrichten",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Wir würden dir gerne eine wöchentliche Zusammenfassung schicken, aber momentan laufen keine Umfragen für {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Nutzer-Insights – Letzte Woche von Formbricks"
|
||||
"verified_link_survey_email_subject": "Deine Umfrage ist bereit zum Ausfüllen."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Brauchst Du Slack- oder Discord-Benachrichtigungen",
|
||||
"notification_settings_updated": "Benachrichtigungseinstellungen aktualisiert",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Richte eine Benachrichtigung ein, um eine E-Mail bei neuen Antworten zu erhalten",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Bleib auf dem Laufenden mit einem wöchentlichen Update jeden Montag",
|
||||
"use_the_integration": "Integration nutzen",
|
||||
"want_to_loop_in_organization_mates": "Willst Du die Organisationskollegen einbeziehen?",
|
||||
"weekly_summary_projects": "Wöchentliche Zusammenfassung (Projekte)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Du wirst nicht mehr automatisch zu den Umfragen dieser Organisation angemeldet!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Du wirst keine weiteren E-Mails für Antworten auf diese Umfrage erhalten!"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "Postleitzahl"
|
||||
},
|
||||
"error_deleting_survey": "Beim Löschen der Umfrage ist ein Fehler aufgetreten",
|
||||
"failed_to_copy_link_to_results": "Kopieren des Links zu den Ergebnissen fehlgeschlagen",
|
||||
"failed_to_copy_url": "Kopieren der URL fehlgeschlagen: nicht in einer Browserumgebung.",
|
||||
"new_survey": "Neue Umfrage",
|
||||
"no_surveys_created_yet": "Noch keine Umfragen erstellt",
|
||||
"open_options": "Optionen öffnen",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Diese Antwort ist in Bearbeitung.",
|
||||
"zip_post_code": "PLZ / Postleitzahl"
|
||||
},
|
||||
"results_unpublished_successfully": "Ergebnisse wurden nicht erfolgreich veröffentlicht.",
|
||||
"search_by_survey_name": "Nach Umfragenamen suchen",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "Benachrichtigungen konfigurieren",
|
||||
"congrats": "Glückwunsch! Deine Umfrage ist jetzt live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Verbinde deine Website oder App mit Formbricks, um loszulegen.",
|
||||
"copy_link_to_public_results": "Link zu öffentlichen Ergebnissen kopieren",
|
||||
"custom_range": "Benutzerdefinierter Bereich...",
|
||||
"delete_all_existing_responses_and_displays": "Alle bestehenden Antworten und Anzeigen löschen",
|
||||
"download_qr_code": "QR Code herunterladen",
|
||||
"drop_offs": "Drop-Off Rate",
|
||||
"drop_offs_tooltip": "So oft wurde die Umfrage gestartet, aber nicht abgeschlossen.",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "Letztes Monat",
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
"publish_to_web": "Im Web veröffentlichen",
|
||||
"publish_to_web_warning": "Du bist dabei, diese Umfrageergebnisse öffentlich zugänglich zu machen.",
|
||||
"publish_to_web_warning_description": "Deine Umfrageergebnisse werden öffentlich sein. Jeder außerhalb deiner Organisation kann darauf zugreifen, wenn er den Link hat.",
|
||||
"qr_code": "QR-Code",
|
||||
"qr_code_description": "Antworten, die per QR-Code gesammelt werden, sind anonym.",
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"reset_survey": "Umfrage zurücksetzen",
|
||||
"reset_survey_warning": "Das Zurücksetzen einer Umfrage entfernt alle Antworten und Anzeigen, die mit dieser Umfrage verbunden sind. Dies kann nicht rückgängig gemacht werden.",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
"setup_integrations": "Integrationen einrichten",
|
||||
"share_results": "Ergebnisse teilen",
|
||||
"share_survey": "Umfrage teilen",
|
||||
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",
|
||||
"show_all_responses_where": "Zeige alle Antworten, bei denen...",
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_results_are_public": "Deine Umfrageergebnisse sind öffentlich",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Deine Umfrageergebnisse stehen allen zur Verfügung, die den Link haben. Die Ergebnisse werden nicht von Suchmaschinen indexiert.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit bis zum Abschluss der Umfrage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"unpublish_from_web": "Aus dem Web entfernen",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"view_site": "Seite ansehen",
|
||||
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8♂️",
|
||||
"whats_next": "Was kommt als Nächstes?",
|
||||
"your_survey_is_public": "Deine Umfrage ist öffentlich",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Dieser Benutzer hat alle Rechte."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Zurück zur Startseite",
|
||||
"page_not_found": "Seite nicht gefunden",
|
||||
"page_not_found_description": "Entschuldigung, wir konnten die gesuchten Antworten mit der geteilten ID nicht finden."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Frag nach einer Adresse",
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "Limits Reached",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link & Email",
|
||||
"link_copied": "Link copied to clipboard!",
|
||||
"link_survey": "Link Survey",
|
||||
"link_surveys": "Link Surveys",
|
||||
"load_more": "Load more",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "Privacy Policy",
|
||||
"product_manager": "Product Manager",
|
||||
"profile": "Profile",
|
||||
"project": "Project",
|
||||
"project_configuration": "Project's Configuration",
|
||||
"project_id": "Project ID",
|
||||
"project_name": "Project Name",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weekly_summary": "Weekly summary",
|
||||
"welcome_card": "Welcome card",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "Your colleague",
|
||||
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
|
||||
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
|
||||
"live_survey_notification_completed": "Completed",
|
||||
"live_survey_notification_draft": "Draft",
|
||||
"live_survey_notification_in_progress": "In Progress",
|
||||
"live_survey_notification_no_new_response": "No new response received this week \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "No Responses yet!",
|
||||
"live_survey_notification_paused": "Paused",
|
||||
"live_survey_notification_scheduled": "Scheduled",
|
||||
"live_survey_notification_view_more_responses": "View {responseCount} more Responses",
|
||||
"live_survey_notification_view_previous_responses": "View previous responses",
|
||||
"live_survey_notification_view_response": "View Response",
|
||||
"new_email_verification_text": "To verify your new email address, please click the button below:",
|
||||
"notification_footer_all_the_best": "All the best,",
|
||||
"notification_footer_in_your_settings": "in your settings \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "please turn them off",
|
||||
"notification_footer_the_formbricks_team": "The Formbricks Team \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "To halt Weekly Updates,",
|
||||
"notification_header_hey": "Hey \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Weekly Report for",
|
||||
"notification_insight_completed": "Completed",
|
||||
"notification_insight_completion_rate": "Completion %",
|
||||
"notification_insight_displays": "Displays",
|
||||
"notification_insight_responses": "Responses",
|
||||
"notification_insight_surveys": "Surveys",
|
||||
"password_changed_email_heading": "Password changed",
|
||||
"password_changed_email_text": "Your password has been changed successfully.",
|
||||
"password_reset_notify_email_subject": "Your Formbricks password has been changed",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verify email",
|
||||
"verification_new_email_subject": "Email change verification",
|
||||
"verification_security_notice": "If you did not request this email change, please ignore this email or contact support immediately.",
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Pick a 15-minute slot in our CEOs calendar",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Don't let a week pass without learning about your users:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Need help finding the right survey for your product?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "or reply to this email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Setup a new survey",
|
||||
"weekly_summary_create_reminder_notification_body_text": "We'd love to send you a Weekly Summary, but currently there are no surveys running for {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} User Insights - Last Week by Formbricks"
|
||||
"verified_link_survey_email_subject": "Your survey is ready to be filled out."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Need Slack or Discord notifications",
|
||||
"notification_settings_updated": "Notification settings updated",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Set up an alert to get an email on new responses",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Stay up-to-date with a Weekly every Monday",
|
||||
"use_the_integration": "Use the integration",
|
||||
"want_to_loop_in_organization_mates": "Want to loop in organization mates",
|
||||
"weekly_summary_projects": "Weekly summary (Projects)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "You will not be auto-subscribed to this organization's surveys anymore!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "You will not receive any more emails for responses on this survey!"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "An error occured while deleting survey",
|
||||
"failed_to_copy_link_to_results": "Failed to copy link to results",
|
||||
"failed_to_copy_url": "Failed to copy URL: not in a browser environment.",
|
||||
"new_survey": "New Survey",
|
||||
"no_surveys_created_yet": "No surveys created yet",
|
||||
"open_options": "Open options",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "This response is in progress.",
|
||||
"zip_post_code": "ZIP / Post code"
|
||||
},
|
||||
"results_unpublished_successfully": "Results unpublished successfully.",
|
||||
"search_by_survey_name": "Search by survey name",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "Configure alerts",
|
||||
"congrats": "Congrats! Your survey is live.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connect your website or app with Formbricks to get started.",
|
||||
"copy_link_to_public_results": "Copy link to public results",
|
||||
"custom_range": "Custom range...",
|
||||
"delete_all_existing_responses_and_displays": "Delete all existing responses and displays",
|
||||
"download_qr_code": "Download QR code",
|
||||
"drop_offs": "Drop-Offs",
|
||||
"drop_offs_tooltip": "Number of times the survey has been started but not completed.",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "Last month",
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
"publish_to_web": "Publish to web",
|
||||
"publish_to_web_warning": "You are about to release these survey results to the public.",
|
||||
"publish_to_web_warning_description": "Your survey results will be public. Anyone outside your organization can access them if they have the link.",
|
||||
"qr_code": "QR code",
|
||||
"qr_code_description": "Responses collected via QR code are anonymous.",
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||
"results_are_public": "Results are public",
|
||||
"reset_survey": "Reset survey",
|
||||
"reset_survey_warning": "Resetting a survey removes all responses and displays associated with this survey. This cannot be undone.",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
"setup_integrations": "Setup integrations",
|
||||
"share_results": "Share results",
|
||||
"share_survey": "Share survey",
|
||||
"show_all_responses_that_match": "Show all responses that match",
|
||||
"show_all_responses_where": "Show all responses where...",
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_results_are_public": "Your survey results are public!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Your survey results are shared with anyone who has the link. The results will not be indexed by search engines.",
|
||||
"survey_reset_successfully": "Survey reset successfully! {responseCount} responses and {displayCount} displays were deleted.",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_tooltip": "Average time to complete the survey.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"unpublish_from_web": "Unpublish from web",
|
||||
"use_personal_links": "Use personal links",
|
||||
"view_site": "View site",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
"whats_next": "What's next?",
|
||||
"your_survey_is_public": "Your survey is public",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "This user has all the power."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Back to home",
|
||||
"page_not_found": "Page not found",
|
||||
"page_not_found_description": "Sorry, we couldn't find the responses sharing ID you're looking for."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Address",
|
||||
"address_description": "Ask for a mailing address",
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "Limites atteints",
|
||||
"link": "Lien",
|
||||
"link_and_email": "Liens et e-mail",
|
||||
"link_copied": " lien copié dans le presse-papiers !",
|
||||
"link_survey": "Enquête de lien",
|
||||
"link_surveys": "Sondages de lien",
|
||||
"load_more": "Charger plus",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "Politique de confidentialité",
|
||||
"product_manager": "Chef de produit",
|
||||
"profile": "Profil",
|
||||
"project": "Projet",
|
||||
"project_configuration": "Configuration du projet",
|
||||
"project_id": "ID de projet",
|
||||
"project_name": "Nom du projet",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "Connexion Site Web & Application",
|
||||
"website_app_survey": "Sondage sur le site Web et l'application",
|
||||
"website_survey": "Sondage de site web",
|
||||
"weekly_summary": "Résumé hebdomadaire",
|
||||
"welcome_card": "Carte de bienvenue",
|
||||
"you": "Vous",
|
||||
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "Votre collègue",
|
||||
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
|
||||
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
|
||||
"live_survey_notification_completed": "Terminé",
|
||||
"live_survey_notification_draft": "Brouillon",
|
||||
"live_survey_notification_in_progress": "En cours",
|
||||
"live_survey_notification_no_new_response": "Aucune nouvelle réponse reçue cette semaine \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Aucune réponse pour le moment !",
|
||||
"live_survey_notification_paused": "En pause",
|
||||
"live_survey_notification_scheduled": "Programmé",
|
||||
"live_survey_notification_view_more_responses": "Voir {responseCount} réponses supplémentaires",
|
||||
"live_survey_notification_view_previous_responses": "Voir les réponses précédentes",
|
||||
"live_survey_notification_view_response": "Voir la réponse",
|
||||
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
|
||||
"notification_footer_all_the_best": "Tous mes vœux,",
|
||||
"notification_footer_in_your_settings": "dans vos paramètres \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "veuillez les éteindre",
|
||||
"notification_footer_the_formbricks_team": "L'équipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Pour arrêter les mises à jour hebdomadaires,",
|
||||
"notification_header_hey": "Salut \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Rapport hebdomadaire pour",
|
||||
"notification_insight_completed": "Terminé",
|
||||
"notification_insight_completion_rate": "Pourcentage d'achèvement",
|
||||
"notification_insight_displays": "Affichages",
|
||||
"notification_insight_responses": "Réponses",
|
||||
"notification_insight_surveys": "Enquêtes",
|
||||
"password_changed_email_heading": "Mot de passe changé",
|
||||
"password_changed_email_text": "Votre mot de passe a été changé avec succès.",
|
||||
"password_reset_notify_email_subject": "Ton mot de passe Formbricks a été changé",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "Vérifier l'email",
|
||||
"verification_new_email_subject": "Vérification du changement d'email",
|
||||
"verification_security_notice": "Si vous n'avez pas demandé ce changement d'email, veuillez ignorer cet email ou contacter le support immédiatement.",
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Choisissez un créneau de 15 minutes dans le calendrier de notre PDG.",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Ne laissez pas une semaine passer sans en apprendre davantage sur vos utilisateurs :",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Besoin d'aide pour trouver le bon sondage pour votre produit ?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou répondez à cet e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurer une nouvelle enquête",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Nous aimerions vous envoyer un résumé hebdomadaire, mais actuellement, il n'y a pas d'enquêtes en cours pour {projectName}.",
|
||||
"weekly_summary_email_subject": "Aperçu des utilisateurs de {projectName} – La semaine dernière par Formbricks"
|
||||
"verified_link_survey_email_subject": "Votre enquête est prête à être remplie."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Besoin de notifications Slack ou Discord",
|
||||
"notification_settings_updated": "Paramètres de notification mis à jour",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurez une alerte pour recevoir un e-mail lors de nouvelles réponses.",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Restez à jour avec un hebdomadaire chaque lundi.",
|
||||
"use_the_integration": "Utilisez l'intégration",
|
||||
"want_to_loop_in_organization_mates": "Voulez-vous inclure des collègues de l'organisation ?",
|
||||
"weekly_summary_projects": "Résumé hebdomadaire (Projets)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Vous ne serez plus automatiquement abonné aux enquêtes de cette organisation !",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Vous ne recevrez plus d'e-mails concernant les réponses à cette enquête !"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "Zip"
|
||||
},
|
||||
"error_deleting_survey": "Une erreur est survenue lors de la suppression de l'enquête.",
|
||||
"failed_to_copy_link_to_results": "Échec de la copie du lien vers les résultats",
|
||||
"failed_to_copy_url": "Échec de la copie de l'URL : pas dans un environnement de navigateur.",
|
||||
"new_survey": "Nouveau Sondage",
|
||||
"no_surveys_created_yet": "Aucun sondage créé pour le moment",
|
||||
"open_options": "Ouvrir les options",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Cette réponse est en cours.",
|
||||
"zip_post_code": "Code postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Résultats publiés avec succès.",
|
||||
"search_by_survey_name": "Recherche par nom d'enquête",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "Configurer les alertes",
|
||||
"congrats": "Félicitations ! Votre enquête est en ligne.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Connectez votre site web ou votre application à Formbricks pour commencer.",
|
||||
"copy_link_to_public_results": "Copier le lien vers les résultats publics",
|
||||
"custom_range": "Plage personnalisée...",
|
||||
"delete_all_existing_responses_and_displays": "Supprimer toutes les réponses existantes et les affichages",
|
||||
"download_qr_code": "Télécharger code QR",
|
||||
"drop_offs": "Dépôts",
|
||||
"drop_offs_tooltip": "Nombre de fois que l'enquête a été commencée mais non terminée.",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "Le mois dernier",
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
"overall": "Globalement",
|
||||
"publish_to_web": "Publier sur le web",
|
||||
"publish_to_web_warning": "Vous êtes sur le point de rendre ces résultats d'enquête publics.",
|
||||
"publish_to_web_warning_description": "Les résultats de votre enquête seront publics. Toute personne en dehors de votre organisation pourra y accéder si elle a le lien.",
|
||||
"qr_code": "Code QR",
|
||||
"qr_code_description": "Les réponses collectées via le code QR sont anonymes.",
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"reset_survey": "Réinitialiser l'enquête",
|
||||
"reset_survey_warning": "Réinitialiser un sondage supprime toutes les réponses et les affichages associés à ce sondage. Cela ne peut pas être annulé.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
"setup_integrations": "Configurer les intégrations",
|
||||
"share_results": "Partager les résultats",
|
||||
"share_survey": "Partager l'enquête",
|
||||
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",
|
||||
"show_all_responses_where": "Afficher toutes les réponses où...",
|
||||
"starts": "Commence",
|
||||
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
|
||||
"survey_results_are_public": "Les résultats de votre enquête sont publics !",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Les résultats de votre enquête sont partagés avec quiconque possède le lien. Les résultats ne seront pas indexés par les moteurs de recherche.",
|
||||
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
|
||||
"this_month": "Ce mois-ci",
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_tooltip": "Temps moyen pour compléter l'enquête.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"unpublish_from_web": "Désactiver la publication sur le web",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"view_site": "Voir le site",
|
||||
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8♂️",
|
||||
"whats_next": "Qu'est-ce qui vient ensuite ?",
|
||||
"your_survey_is_public": "Votre enquête est publique.",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Cet utilisateur a tout le pouvoir."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Retour à l'accueil",
|
||||
"page_not_found": "Page non trouvée",
|
||||
"page_not_found_description": "Désolé, nous n'avons pas pu trouver l'ID de partage des réponses que vous recherchez."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Adresse",
|
||||
"address_description": "Demander une adresse postale",
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "link",
|
||||
"link_and_email": "Link & E-mail",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Pesquisa de Link",
|
||||
"link_surveys": "Link de Pesquisas",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gerente de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "Conexão de Site e App",
|
||||
"website_app_survey": "Pesquisa de Site e App",
|
||||
"website_survey": "Pesquisa de Site",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "Seu colega",
|
||||
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em andamento",
|
||||
"live_survey_notification_no_new_response": "Nenhuma resposta nova recebida essa semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas configurações \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desliga eles",
|
||||
"notification_footer_the_formbricks_team": "A Equipe Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Oi \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal de",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "telas",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "pesquisas",
|
||||
"password_changed_email_heading": "Senha alterada",
|
||||
"password_changed_email_text": "Sua senha foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "Sua senha Formbricks foi alterada",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verificar e-mail",
|
||||
"verification_new_email_subject": "Verificação de alteração de e-mail",
|
||||
"verification_security_notice": "Se você não solicitou essa mudança de email, por favor ignore este email ou entre em contato com o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um horário de 15 minutos na agenda do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe uma semana passar sem aprender sobre seus usuários:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda pra encontrar a pesquisa certa pro seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responde a esse e-mail :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar uma nova pesquisa",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Adoraríamos te enviar um Resumo Semanal, mas no momento não há pesquisas em andamento para {projectName}.",
|
||||
"weekly_summary_email_subject": "Insights de usuários do {projectName} – Semana passada por Formbricks"
|
||||
"verified_link_survey_email_subject": "Sua pesquisa está pronta para ser preenchida."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Preciso de notificações no Slack ou Discord",
|
||||
"notification_settings_updated": "Configurações de notificação atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configura um alerta pra receber um e-mail com novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Fique por dentro com um resumo semanal toda segunda-feira",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quero incluir os colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Você não vai ser mais inscrito automaticamente nas pesquisas dessa organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Você não vai receber mais e-mails sobre respostas dessa pesquisa!"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "Fecho éclair"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao deletar a pesquisa",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link dos resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está em um ambiente de navegador.",
|
||||
"new_survey": "Nova Pesquisa",
|
||||
"no_surveys_created_yet": "Ainda não foram criadas pesquisas",
|
||||
"open_options": "Abre opções",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Essa resposta está em andamento.",
|
||||
"zip_post_code": "CEP / Código postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados não publicados com sucesso.",
|
||||
"search_by_survey_name": "Buscar pelo nome da pesquisa",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! Sua pesquisa está no ar.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Conecte seu site ou app com o Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas e exibições existentes",
|
||||
"download_qr_code": "baixar código QR",
|
||||
"drop_offs": "Pontos de Entrega",
|
||||
"drop_offs_tooltip": "Número de vezes que a pesquisa foi iniciada mas não concluída.",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "No geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Você está prestes a divulgar esses resultados da pesquisa para o público.",
|
||||
"publish_to_web_warning_description": "Os resultados da sua pesquisa serão públicos. Qualquer pessoa fora da sua organização pode acessá-los se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas coletadas via código QR são anônimas.",
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"reset_survey": "Redefinir pesquisa",
|
||||
"reset_survey_warning": "Redefinir uma pesquisa remove todas as respostas e exibições associadas a esta pesquisa. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Compartilhar resultados",
|
||||
"share_survey": "Compartilhar pesquisa",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostre todas as respostas onde...",
|
||||
"starts": "começa",
|
||||
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
|
||||
"survey_results_are_public": "Os resultados da sua pesquisa são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados da sua pesquisa são compartilhados com quem tiver o link. Os resultados não serão indexados por motores de busca.",
|
||||
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "E agora?",
|
||||
"your_survey_is_public": "Sua pesquisa é pública",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Esse usuário tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar pra casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpa, não conseguimos encontrar as respostas com o ID que você está procurando."
|
||||
},
|
||||
"templates": {
|
||||
"address": "endereço",
|
||||
"address_description": "Pede um endereço pra correspondência",
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "Limites Atingidos",
|
||||
"link": "Link",
|
||||
"link_and_email": "Link e Email",
|
||||
"link_copied": "Link copiado para a área de transferência!",
|
||||
"link_survey": "Ligar Inquérito",
|
||||
"link_surveys": "Ligar Inquéritos",
|
||||
"load_more": "Carregar mais",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "Política de Privacidade",
|
||||
"product_manager": "Gestor de Produto",
|
||||
"profile": "Perfil",
|
||||
"project": "Projeto",
|
||||
"project_configuration": "Configuração do Projeto",
|
||||
"project_id": "ID do Projeto",
|
||||
"project_name": "Nome do Projeto",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "Ligação de Website e Aplicação",
|
||||
"website_app_survey": "Inquérito do Website e da Aplicação",
|
||||
"website_survey": "Inquérito do Website",
|
||||
"weekly_summary": "Resumo semanal",
|
||||
"welcome_card": "Cartão de boas-vindas",
|
||||
"you": "Você",
|
||||
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "O seu colega",
|
||||
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
|
||||
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
|
||||
"live_survey_notification_completed": "Concluído",
|
||||
"live_survey_notification_draft": "Rascunho",
|
||||
"live_survey_notification_in_progress": "Em Progresso",
|
||||
"live_survey_notification_no_new_response": "Nenhuma nova resposta recebida esta semana \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "Ainda sem respostas!",
|
||||
"live_survey_notification_paused": "Pausado",
|
||||
"live_survey_notification_scheduled": "Agendado",
|
||||
"live_survey_notification_view_more_responses": "Ver mais {responseCount} respostas",
|
||||
"live_survey_notification_view_previous_responses": "Ver respostas anteriores",
|
||||
"live_survey_notification_view_response": "Ver Resposta",
|
||||
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
|
||||
"notification_footer_all_the_best": "Tudo de bom,",
|
||||
"notification_footer_in_your_settings": "nas suas definições \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "por favor, desative-os",
|
||||
"notification_footer_the_formbricks_team": "A Equipa Formbricks \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "Para parar as Atualizações Semanais,",
|
||||
"notification_header_hey": "Olá \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "Relatório Semanal para",
|
||||
"notification_insight_completed": "Concluído",
|
||||
"notification_insight_completion_rate": "Conclusão %",
|
||||
"notification_insight_displays": "Ecrãs",
|
||||
"notification_insight_responses": "Respostas",
|
||||
"notification_insight_surveys": "Inquéritos",
|
||||
"password_changed_email_heading": "Palavra-passe alterada",
|
||||
"password_changed_email_text": "A sua palavra-passe foi alterada com sucesso.",
|
||||
"password_reset_notify_email_subject": "A sua palavra-passe do Formbricks foi alterada",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "Verificar email",
|
||||
"verification_new_email_subject": "Verificação de alteração de email",
|
||||
"verification_security_notice": "Se não solicitou esta alteração de email, ignore este email ou contacte o suporte imediatamente.",
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido.",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "Escolha um intervalo de 15 minutos no calendário do nosso CEO",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "Não deixe passar uma semana sem aprender sobre os seus utilizadores:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "Precisa de ajuda para encontrar o inquérito certo para o seu produto?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "ou responda a este email :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "Configurar um novo inquérito",
|
||||
"weekly_summary_create_reminder_notification_body_text": "Gostaríamos de lhe enviar um Resumo Semanal, mas de momento não há inquéritos a decorrer para {projectName}.",
|
||||
"weekly_summary_email_subject": "{projectName} Informações do Utilizador - Última Semana por Formbricks"
|
||||
"verified_link_survey_email_subject": "O seu inquérito está pronto para ser preenchido."
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "Precisa de notificações do Slack ou Discord",
|
||||
"notification_settings_updated": "Definições de notificações atualizadas",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "Configurar um alerta para receber um e-mail sobre novas respostas",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "Mantenha-se atualizado com um Resumo semanal todas as segundas-feiras",
|
||||
"use_the_integration": "Use a integração",
|
||||
"want_to_loop_in_organization_mates": "Quer incluir colegas da organização",
|
||||
"weekly_summary_projects": "Resumo semanal (Projetos)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "Já não será automaticamente subscrito aos inquéritos desta organização!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "Não receberá mais emails para respostas a este inquérito!"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "Comprimir"
|
||||
},
|
||||
"error_deleting_survey": "Ocorreu um erro ao eliminar o questionário",
|
||||
"failed_to_copy_link_to_results": "Falha ao copiar link para resultados",
|
||||
"failed_to_copy_url": "Falha ao copiar URL: não está num ambiente de navegador.",
|
||||
"new_survey": "Novo inquérito",
|
||||
"no_surveys_created_yet": "Ainda não foram criados questionários",
|
||||
"open_options": "Abrir opções",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "Esta resposta está em progresso.",
|
||||
"zip_post_code": "Código Postal"
|
||||
},
|
||||
"results_unpublished_successfully": "Resultados despublicados com sucesso.",
|
||||
"search_by_survey_name": "Pesquisar por nome do inquérito",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "Configurar alertas",
|
||||
"congrats": "Parabéns! O seu inquérito está ativo.",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "Ligue o seu website ou aplicação ao Formbricks para começar.",
|
||||
"copy_link_to_public_results": "Copiar link para resultados públicos",
|
||||
"custom_range": "Intervalo personalizado...",
|
||||
"delete_all_existing_responses_and_displays": "Excluir todas as respostas existentes e exibições",
|
||||
"download_qr_code": "Transferir código QR",
|
||||
"drop_offs": "Desistências",
|
||||
"drop_offs_tooltip": "Número de vezes que o inquérito foi iniciado mas não concluído.",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "Último mês",
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
"overall": "Geral",
|
||||
"publish_to_web": "Publicar na web",
|
||||
"publish_to_web_warning": "Está prestes a divulgar estes resultados do inquérito ao público.",
|
||||
"publish_to_web_warning_description": "Os resultados do seu inquérito serão públicos. Qualquer pessoa fora da sua organização pode aceder a eles se tiver o link.",
|
||||
"qr_code": "Código QR",
|
||||
"qr_code_description": "Respostas recolhidas através de código QR são anónimas.",
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"reset_survey": "Reiniciar inquérito",
|
||||
"reset_survey_warning": "Repor um inquérito remove todas as respostas e visualizações associadas a este inquérito. Isto não pode ser desfeito.",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
"setup_integrations": "Configurar integrações",
|
||||
"share_results": "Partilhar resultados",
|
||||
"share_survey": "Partilhar inquérito",
|
||||
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",
|
||||
"show_all_responses_where": "Mostrar todas as respostas onde...",
|
||||
"starts": "Começa",
|
||||
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
|
||||
"survey_results_are_public": "Os resultados do seu inquérito são públicos!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "Os resultados do seu inquérito são partilhados com qualquer pessoa que tenha o link. Os resultados não serão indexados pelos motores de busca.",
|
||||
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
|
||||
"this_month": "Este mês",
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_tooltip": "Tempo médio para concluir o inquérito.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"unpublish_from_web": "Despublicar da web",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8♂️",
|
||||
"whats_next": "O que se segue?",
|
||||
"your_survey_is_public": "O seu inquérito é público",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "Este utilizador tem todo o poder."
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "Voltar para casa",
|
||||
"page_not_found": "Página não encontrada",
|
||||
"page_not_found_description": "Desculpe, não conseguimos encontrar o ID de partilha de respostas que está a procurar."
|
||||
},
|
||||
"templates": {
|
||||
"address": "Endereço",
|
||||
"address_description": "Pedir um endereço de correspondência",
|
||||
|
||||
@@ -236,7 +236,6 @@
|
||||
"limits_reached": "已達上限",
|
||||
"link": "連結",
|
||||
"link_and_email": "連結與電子郵件",
|
||||
"link_copied": "連結已複製到剪貼簿!",
|
||||
"link_survey": "連結問卷",
|
||||
"link_surveys": "連結問卷",
|
||||
"load_more": "載入更多",
|
||||
@@ -303,7 +302,6 @@
|
||||
"privacy": "隱私權政策",
|
||||
"product_manager": "產品經理",
|
||||
"profile": "個人資料",
|
||||
"project": "專案",
|
||||
"project_configuration": "專案組態",
|
||||
"project_id": "專案 ID",
|
||||
"project_name": "專案名稱",
|
||||
@@ -413,7 +411,6 @@
|
||||
"website_and_app_connection": "網站與應用程式連線",
|
||||
"website_app_survey": "網站與應用程式問卷",
|
||||
"website_survey": "網站問卷",
|
||||
"weekly_summary": "每週摘要",
|
||||
"welcome_card": "歡迎卡片",
|
||||
"you": "您",
|
||||
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
|
||||
@@ -454,29 +451,7 @@
|
||||
"invite_email_text_par1": "您的同事",
|
||||
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請,請點擊以下連結:",
|
||||
"invite_member_email_subject": "您被邀請協作 Formbricks!",
|
||||
"live_survey_notification_completed": "已完成",
|
||||
"live_survey_notification_draft": "草稿",
|
||||
"live_survey_notification_in_progress": "進行中",
|
||||
"live_survey_notification_no_new_response": "本週沒有收到新的回應 \uD83D\uDD75️",
|
||||
"live_survey_notification_no_responses_yet": "尚無回應!",
|
||||
"live_survey_notification_paused": "已暫停",
|
||||
"live_survey_notification_scheduled": "已排程",
|
||||
"live_survey_notification_view_more_responses": "檢視另外 '{'responseCount'}' 個回應",
|
||||
"live_survey_notification_view_previous_responses": "檢視先前的回應",
|
||||
"live_survey_notification_view_response": "檢視回應",
|
||||
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
|
||||
"notification_footer_all_the_best": "祝您一切順利,",
|
||||
"notification_footer_in_your_settings": "在您的設定中 \uD83D\uDE4F",
|
||||
"notification_footer_please_turn_them_off": "請關閉它們",
|
||||
"notification_footer_the_formbricks_team": "Formbricks 團隊 \uD83E\uDD0D",
|
||||
"notification_footer_to_halt_weekly_updates": "若要停止每週更新,",
|
||||
"notification_header_hey": "嗨 \uD83D\uDC4B",
|
||||
"notification_header_weekly_report_for": "每週報告,適用於",
|
||||
"notification_insight_completed": "已完成",
|
||||
"notification_insight_completion_rate": "完成率 %",
|
||||
"notification_insight_displays": "顯示次數",
|
||||
"notification_insight_responses": "回應數",
|
||||
"notification_insight_surveys": "問卷數",
|
||||
"password_changed_email_heading": "密碼已變更",
|
||||
"password_changed_email_text": "您的密碼已成功變更。",
|
||||
"password_reset_notify_email_subject": "您的 Formbricks 密碼已變更",
|
||||
@@ -509,14 +484,7 @@
|
||||
"verification_email_verify_email": "驗證電子郵件",
|
||||
"verification_new_email_subject": "電子郵件更改驗證",
|
||||
"verification_security_notice": "如果您沒有要求更改此電子郵件,請忽略此電子郵件或立即聯繫支援。",
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。",
|
||||
"weekly_summary_create_reminder_notification_body_cal_slot": "在我們 CEO 的日曆中選擇一個 15 分鐘的時段",
|
||||
"weekly_summary_create_reminder_notification_body_dont_let_a_week_pass": "不要讓一週過去而沒有了解您的使用者:",
|
||||
"weekly_summary_create_reminder_notification_body_need_help": "需要協助找到適合您產品的問卷嗎?",
|
||||
"weekly_summary_create_reminder_notification_body_reply_email": "或回覆此電子郵件 :)",
|
||||
"weekly_summary_create_reminder_notification_body_setup_a_new_survey": "設定新的問卷",
|
||||
"weekly_summary_create_reminder_notification_body_text": "我們很樂意向您發送每週摘要,但目前 '{'projectName'}' 沒有正在執行的問卷。",
|
||||
"weekly_summary_email_subject": "{projectName} 用戶洞察 - 上週 by Formbricks"
|
||||
"verified_link_survey_email_subject": "您的 survey 已準備好填寫。"
|
||||
},
|
||||
"environments": {
|
||||
"actions": {
|
||||
@@ -1118,10 +1086,8 @@
|
||||
"need_slack_or_discord_notifications": "需要 Slack 或 Discord 通知嗎?",
|
||||
"notification_settings_updated": "通知設定已更新",
|
||||
"set_up_an_alert_to_get_an_email_on_new_responses": "設定警示以在收到新回應時收到電子郵件",
|
||||
"stay_up_to_date_with_a_Weekly_every_Monday": "每週一使用每週摘要保持最新資訊",
|
||||
"use_the_integration": "使用整合",
|
||||
"want_to_loop_in_organization_mates": "想要讓組織夥伴也參與嗎?",
|
||||
"weekly_summary_projects": "每週摘要(專案)",
|
||||
"you_will_not_be_auto_subscribed_to_this_organizations_surveys_anymore": "您將不會再自動訂閱此組織的問卷!",
|
||||
"you_will_not_receive_any_more_emails_for_responses_on_this_survey": "您將不會再收到此問卷回應的電子郵件!"
|
||||
},
|
||||
@@ -1636,8 +1602,6 @@
|
||||
"zip": "郵遞區號"
|
||||
},
|
||||
"error_deleting_survey": "刪除問卷時發生錯誤",
|
||||
"failed_to_copy_link_to_results": "無法複製結果連結",
|
||||
"failed_to_copy_url": "無法複製網址:不在瀏覽器環境中。",
|
||||
"new_survey": "新增問卷",
|
||||
"no_surveys_created_yet": "尚未建立任何問卷",
|
||||
"open_options": "開啟選項",
|
||||
@@ -1678,7 +1642,6 @@
|
||||
"this_response_is_in_progress": "此回應正在進行中。",
|
||||
"zip_post_code": "郵遞區號"
|
||||
},
|
||||
"results_unpublished_successfully": "結果已成功取消發布。",
|
||||
"search_by_survey_name": "依問卷名稱搜尋",
|
||||
"share": {
|
||||
"anonymous_links": {
|
||||
@@ -1777,8 +1740,8 @@
|
||||
"configure_alerts": "設定警示",
|
||||
"congrats": "恭喜!您的問卷已上線。",
|
||||
"connect_your_website_or_app_with_formbricks_to_get_started": "將您的網站或應用程式與 Formbricks 連線以開始使用。",
|
||||
"copy_link_to_public_results": "複製公開結果的連結",
|
||||
"custom_range": "自訂範圍...",
|
||||
"delete_all_existing_responses_and_displays": "刪除 所有 現有 回應 和 顯示",
|
||||
"download_qr_code": "下載 QR code",
|
||||
"drop_offs": "放棄",
|
||||
"drop_offs_tooltip": "問卷已開始但未完成的次數。",
|
||||
@@ -1830,40 +1793,33 @@
|
||||
"last_month": "上個月",
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
"overall": "整體",
|
||||
"publish_to_web": "發布至網站",
|
||||
"publish_to_web_warning": "您即將將這些問卷結果發布到公共領域。",
|
||||
"publish_to_web_warning_description": "您的問卷結果將會是公開的。任何組織外的人員都可以存取這些結果(如果他們有連結)。",
|
||||
"qr_code": "QR 碼",
|
||||
"qr_code_description": "透過 QR code 收集的回應都是匿名的。",
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||
"results_are_public": "結果是公開的",
|
||||
"reset_survey": "重設問卷",
|
||||
"reset_survey_warning": "重置 調查 會 移除 與 此 調查 相關 的 所有 回應 和 顯示 。 這 是 不可 撤銷 的 。",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
"setup_integrations": "設定整合",
|
||||
"share_results": "分享結果",
|
||||
"share_survey": "分享問卷",
|
||||
"show_all_responses_that_match": "顯示所有相符的回應",
|
||||
"show_all_responses_where": "顯示所有回應,其中...",
|
||||
"starts": "開始次數",
|
||||
"starts_tooltip": "問卷已開始的次數。",
|
||||
"survey_results_are_public": "您的問卷結果是公開的!",
|
||||
"survey_results_are_shared_with_anyone_who_has_the_link": "您的問卷結果與任何擁有連結的人員分享。這些結果將不會被搜尋引擎編入索引。",
|
||||
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
|
||||
"this_month": "本月",
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_tooltip": "完成問卷的平均時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"unpublish_from_web": "從網站取消發布",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"view_site": "檢視網站",
|
||||
"waiting_for_response": "正在等待回應 \uD83E\uDDD8♂️",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
@@ -1994,11 +1950,6 @@
|
||||
"this_user_has_all_the_power": "此使用者擁有所有權限。"
|
||||
}
|
||||
},
|
||||
"share": {
|
||||
"back_to_home": "返回首頁",
|
||||
"page_not_found": "找不到頁面",
|
||||
"page_not_found_description": "抱歉,我們找不到您要尋找的回應分享 ID。"
|
||||
},
|
||||
"templates": {
|
||||
"address": "地址",
|
||||
"address_description": "要求郵寄地址",
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
|
||||
interface SurveyLinkDisplayProps {
|
||||
surveyUrl: string;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
export const SurveyLinkDisplay = ({ surveyUrl, enforceSurveyUrlWidth = false }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<>
|
||||
{surveyUrl ? (
|
||||
<Input
|
||||
data-testid="survey-url-input"
|
||||
autoFocus={true}
|
||||
className="h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent"
|
||||
className={cn(
|
||||
"h-9 w-full text-ellipsis rounded-lg border bg-white px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
value={surveyUrl}
|
||||
readOnly
|
||||
aria-label="Survey URL"
|
||||
/>
|
||||
) : (
|
||||
//loading state
|
||||
<div
|
||||
data-testid="loading-div"
|
||||
className="h-9 w-full min-w-96 animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent"
|
||||
className={cn(
|
||||
"h-9 w-full animate-pulse rounded-lg bg-slate-100 px-3 py-1 text-slate-800 caret-transparent",
|
||||
{
|
||||
"min-w-96": enforceSurveyUrlWidth,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ShareSurveyLinkProps {
|
||||
surveyUrl: string;
|
||||
setSurveyUrl: (url: string) => void;
|
||||
locale: TUserLocale;
|
||||
enforceSurveyUrlWidth?: boolean;
|
||||
}
|
||||
|
||||
export const ShareSurveyLink = ({
|
||||
@@ -25,6 +26,7 @@ export const ShareSurveyLink = ({
|
||||
publicDomain,
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
enforceSurveyUrlWidth = false,
|
||||
}: ShareSurveyLinkProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -50,8 +52,12 @@ export const ShareSurveyLink = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<div className={"flex max-w-full items-center justify-center gap-2"}>
|
||||
<SurveyLinkDisplay
|
||||
surveyUrl={surveyUrl}
|
||||
key={surveyUrl}
|
||||
enforceSurveyUrlWidth={enforceSurveyUrlWidth}
|
||||
/>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={handleLanguageChange} locale={locale} />
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
@@ -104,11 +105,10 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||
identifier: authentication.data.hashedApiKey,
|
||||
});
|
||||
if (!rateLimitResponse.ok) {
|
||||
return handleApiError(request, rateLimitResponse.error);
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
|
||||
} catch (error) {
|
||||
return handleApiError(request, { type: "too_many_requests", details: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
@@ -24,20 +28,31 @@ vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAuthentication = {
|
||||
type: "apiKey" as const,
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-id",
|
||||
environmentType: "development" as const,
|
||||
projectId: "project-id",
|
||||
projectName: "Project Name",
|
||||
permission: "manage" as const,
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {} as any,
|
||||
} as any;
|
||||
|
||||
describe("apiWrapper", () => {
|
||||
test("should handle request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
@@ -74,13 +89,7 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -107,14 +116,7 @@ describe("apiWrapper", () => {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
@@ -134,13 +136,7 @@ describe("apiWrapper", () => {
|
||||
test("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -163,14 +159,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle query schema errors", async () => {
|
||||
const request = new Request("http://localhost?foo%ZZ=abc");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
@@ -190,13 +179,7 @@ describe("apiWrapper", () => {
|
||||
test("should parse params schema correctly", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
@@ -220,14 +203,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle no external params", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -248,14 +224,7 @@ describe("apiWrapper", () => {
|
||||
test("should handle params schema errors", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
@@ -273,21 +242,13 @@ describe("apiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit errors", async () => {
|
||||
test("should handle rate limit exceeded", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
|
||||
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: false }));
|
||||
vi.mocked(handleApiError).mockImplementation(
|
||||
(_request: Request, _error: ApiErrorResponseV2): Response =>
|
||||
new Response("rate limit exceeded", { status: 429 })
|
||||
@@ -302,4 +263,24 @@ describe("apiWrapper", () => {
|
||||
expect(response.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle rate limit check failure gracefully", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(ok(mockAuthentication));
|
||||
// When rate limiting fails (e.g., Redis connection issues), checkRateLimit fails open by returning allowed: true
|
||||
vi.mocked(checkRateLimit).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
// Should fail open for availability
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@/lib/constants";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export type RateLimitHelper = {
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
* Using a callback instead of a regular return to provide headers even
|
||||
* when the rate limit is reached and an error is thrown.
|
||||
**/
|
||||
onRateLimiterResponse?: (response: RatelimitResponse) => void;
|
||||
};
|
||||
|
||||
let warningDisplayed = false;
|
||||
|
||||
/** Prevent flooding the logs while testing/building */
|
||||
function logOnce(message: string) {
|
||||
if (warningDisplayed) return;
|
||||
logger.warn(message);
|
||||
warningDisplayed = true;
|
||||
}
|
||||
|
||||
export function rateLimiter() {
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logOnce("Rate limiting disabled");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
|
||||
if (!UNKEY_ROOT_KEY) {
|
||||
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
const timeout = {
|
||||
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
|
||||
ms: 5000,
|
||||
};
|
||||
|
||||
const limiter = {
|
||||
api: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "api",
|
||||
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
|
||||
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
|
||||
timeout,
|
||||
}),
|
||||
};
|
||||
|
||||
async function rateLimit({ identifier, opts }: RateLimitHelper) {
|
||||
return await limiter.api.limit(identifier, opts);
|
||||
}
|
||||
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
export const checkRateLimitAndThrowError = async ({
|
||||
identifier,
|
||||
opts,
|
||||
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const response = await rateLimiter()({ identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (!success) {
|
||||
return err({
|
||||
type: "too_many_requests",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("when rate limiting is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning once and return a stubbed response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
|
||||
const res1 = await rateLimiter()({ identifier: "test-id" });
|
||||
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
|
||||
// Subsequent calls won't log again.
|
||||
await rateLimiter()({ identifier: "another-id" });
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledTimes(1);
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when UNKEY_ROOT_KEY is missing", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "",
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "warn");
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
|
||||
const res = await limiterFunc({ identifier: "test-id" });
|
||||
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(loggerSpy).toHaveBeenCalled();
|
||||
loggerSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when rate limiting is active (enabled)", () => {
|
||||
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
|
||||
let limitMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@/lib/constants");
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "valid-key",
|
||||
}));
|
||||
|
||||
limitMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
const RatelimitMock = vi.fn().mockImplementation(() => {
|
||||
return { limit: limitMock };
|
||||
});
|
||||
vi.doMock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: RatelimitMock,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
|
||||
const { rateLimiter } = await import("../rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
|
||||
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
|
||||
expect(res).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "too_many_requests" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
|
||||
});
|
||||
}
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
return handleApiError(request, surveyUrlResult.error);
|
||||
|
||||
@@ -82,11 +82,11 @@ export const GET = async (
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contacts
|
||||
.map((contact) => {
|
||||
const contactLinks = await Promise.all(
|
||||
contacts.map(async (contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(
|
||||
const surveyUrlResult = await getContactSurveyLink(
|
||||
contactId,
|
||||
params.surveyId,
|
||||
query?.expirationDays || undefined
|
||||
@@ -107,10 +107,11 @@ export const GET = async (
|
||||
expiresAt,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
);
|
||||
|
||||
const filteredContactLinks = contactLinks.filter(Boolean);
|
||||
return responses.successResponse({
|
||||
data: contactLinks,
|
||||
data: filteredContactLinks,
|
||||
meta,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("rateLimitConfigs", () => {
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["profileUpdate", "surveyFollowUp"]);
|
||||
expect(actionConfigs).toEqual(["emailUpdate", "surveyFollowUp"]);
|
||||
});
|
||||
|
||||
test("should have all share configurations", () => {
|
||||
@@ -137,7 +137,7 @@ describe("rateLimitConfigs", () => {
|
||||
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.actions.profileUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.actions.emailUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
|
||||
];
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export const rateLimitConfigs = {
|
||||
|
||||
// Server actions - varies by action type
|
||||
actions: {
|
||||
profileUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:profile" }, // 3 per hour
|
||||
emailUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:email" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
},
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const PricingCard = ({
|
||||
window.open(plan.href, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
className="flex justify-center bg-white">
|
||||
{t(plan.CTA ?? "common.request_pricing")}
|
||||
{plan.CTA ?? t("common.request_pricing")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export const PricingCard = ({
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t(plan.CTA ?? "common.start_free_trial")}
|
||||
{plan.CTA ?? t("common.start_free_trial")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ export const PricingCard = ({
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm font-semibold leading-6"
|
||||
)}>
|
||||
{t(plan.name)}
|
||||
{plan.name}
|
||||
</h2>
|
||||
{isCurrentPlan && (
|
||||
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
|
||||
@@ -155,7 +155,7 @@ export const PricingCard = ({
|
||||
? planPeriod === "monthly"
|
||||
? plan.price.monthly
|
||||
: plan.price.yearly
|
||||
: t(plan.price.monthly)}
|
||||
: plan.price.monthly}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.ENTERPRISE && (
|
||||
<div className="text-sm leading-5">
|
||||
@@ -196,7 +196,7 @@ export const PricingCard = ({
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(mainFeature)}
|
||||
{mainFeature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -215,7 +215,7 @@ export const PricingCard = ({
|
||||
open={upgradeModalOpen}
|
||||
setOpen={setUpgradeModalOpen}
|
||||
text={t("environments.settings.billing.switch_plan_confirmation_text", {
|
||||
plan: t(plan.name),
|
||||
plan: plan.name,
|
||||
price: planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly,
|
||||
period:
|
||||
planPeriod === "monthly"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import * as contactSurveyLink from "./contact-survey-link";
|
||||
|
||||
// Mock all modules needed (this gets hoisted to the top of the file)
|
||||
@@ -33,12 +36,22 @@ vi.mock("@/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/single-use-surveys", () => ({
|
||||
generateSurveySingleUseId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Contact Survey Link", () => {
|
||||
const mockContactId = "contact-123";
|
||||
const mockSurveyId = "survey-456";
|
||||
const mockToken = "mock.jwt.token";
|
||||
const mockEncryptedContactId = "encrypted-contact-id";
|
||||
const mockEncryptedSurveyId = "encrypted-survey-id";
|
||||
const mockedGetSurvey = vi.mocked(getSurvey);
|
||||
const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -60,11 +73,17 @@ describe("Contact Survey Link", () => {
|
||||
contactId: mockEncryptedContactId,
|
||||
surveyId: mockEncryptedSurveyId,
|
||||
} as any);
|
||||
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: false, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id");
|
||||
});
|
||||
|
||||
describe("getContactSurveyLink", () => {
|
||||
test("creates a survey link with encrypted contact and survey IDs", () => {
|
||||
const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
test("creates a survey link with encrypted contact and survey IDs", async () => {
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
// Verify encryption was called for both IDs
|
||||
expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY);
|
||||
@@ -85,11 +104,13 @@ describe("Contact Survey Link", () => {
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}`,
|
||||
});
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("adds expiration to the token when expirationDays is provided", () => {
|
||||
test("adds expiration to the token when expirationDays is provided", async () => {
|
||||
const expirationDays = 7;
|
||||
contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
|
||||
await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays);
|
||||
|
||||
// Verify JWT sign was called with expiration
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
@@ -102,7 +123,55 @@ describe("Contact Survey Link", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("throws an error when ENCRYPTION_KEY is not available", async () => {
|
||||
test("returns a not_found error when survey does not exist", async () => {
|
||||
mockedGetSurvey.mockResolvedValue(null as unknown as TSurvey);
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, "unfound-survey-id");
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "not_found",
|
||||
message: "Survey not found",
|
||||
details: [{ field: "surveyId", issue: "not_found" }],
|
||||
},
|
||||
});
|
||||
expect(mockedGetSurvey).toHaveBeenCalledWith("unfound-survey-id");
|
||||
});
|
||||
|
||||
test("creates a link with unencrypted single use ID when enabled", async () => {
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`,
|
||||
});
|
||||
});
|
||||
|
||||
test("creates a link with encrypted single use ID when enabled and encrypted", async () => {
|
||||
mockedGetSurvey.mockResolvedValue({
|
||||
id: mockSurveyId,
|
||||
singleUse: { enabled: true, isEncrypted: true },
|
||||
} as TSurvey);
|
||||
mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted");
|
||||
|
||||
const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
|
||||
expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns an error when ENCRYPTION_KEY is not available", async () => {
|
||||
// Reset modules so the new mock is used by the module under test
|
||||
vi.resetModules();
|
||||
// Re‑mock constants to simulate missing ENCRYPTION_KEY
|
||||
@@ -113,7 +182,7 @@ describe("Contact Survey Link", () => {
|
||||
// Re‑import the modules so they pick up the new mock
|
||||
const { getContactSurveyLink } = await import("./contact-survey-link");
|
||||
|
||||
const result = getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
const result = await getContactSurveyLink(mockContactId, mockSurveyId);
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
@@ -141,7 +210,7 @@ describe("Contact Survey Link", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("throws an error when token verification fails", () => {
|
||||
test("returns an error when token verification fails", () => {
|
||||
vi.mocked(jwt.verify).mockImplementation(() => {
|
||||
throw new Error("Token verification failed");
|
||||
});
|
||||
@@ -157,7 +226,7 @@ describe("Contact Survey Link", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("throws an error when token has invalid format", () => {
|
||||
test("returns an error when token has invalid format", () => {
|
||||
// Mock JWT.verify to return an incomplete payload
|
||||
vi.mocked(jwt.verify).mockReturnValue({
|
||||
// Missing surveyId
|
||||
@@ -178,7 +247,7 @@ describe("Contact Survey Link", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("throws an error when ENCRYPTION_KEY is not available", async () => {
|
||||
test("returns an error when ENCRYPTION_KEY is not available", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: undefined,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
// Creates an encrypted personalized survey link for a contact
|
||||
export const getContactSurveyLink = (
|
||||
export const getContactSurveyLink = async (
|
||||
contactId: string,
|
||||
surveyId: string,
|
||||
expirationDays?: number
|
||||
): Result<string, ApiErrorResponseV2> => {
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
@@ -19,10 +21,27 @@ export const getContactSurveyLink = (
|
||||
});
|
||||
}
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
message: "Survey not found",
|
||||
details: [{ field: "surveyId", issue: "not_found" }],
|
||||
});
|
||||
}
|
||||
|
||||
const { enabled: isSingleUseEnabled, isEncrypted: isSingleUseEncrypted } = survey.singleUse ?? {};
|
||||
|
||||
// Encrypt the contact and survey IDs
|
||||
const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY);
|
||||
const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY);
|
||||
|
||||
let singleUseId: string | undefined;
|
||||
|
||||
if (isSingleUseEnabled) {
|
||||
singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false);
|
||||
}
|
||||
|
||||
// Create JWT payload with encrypted IDs
|
||||
const payload = {
|
||||
contactId: encryptedContactId,
|
||||
@@ -43,7 +62,9 @@ export const getContactSurveyLink = (
|
||||
const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions);
|
||||
|
||||
// Return the personalized URL
|
||||
return ok(`${getPublicDomain()}/c/${token}`);
|
||||
return singleUseId
|
||||
? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`)
|
||||
: ok(`${getPublicDomain()}/c/${token}`);
|
||||
};
|
||||
|
||||
// Validates and decrypts a contact survey JWT token
|
||||
@@ -59,7 +80,10 @@ export const verifyContactSurveyToken = (
|
||||
|
||||
try {
|
||||
// Verify the token
|
||||
const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string };
|
||||
const decoded = jwt.verify(token, ENCRYPTION_KEY) as {
|
||||
contactId: string;
|
||||
surveyId: string;
|
||||
};
|
||||
|
||||
if (!decoded || !decoded.contactId || !decoded.surveyId) {
|
||||
throw err("Invalid token format");
|
||||
|
||||
@@ -501,11 +501,11 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
|
||||
}
|
||||
|
||||
// Generate survey links for each contact
|
||||
const contactLinks = contactsResult
|
||||
.map((contact) => {
|
||||
const contactLinks = await Promise.all(
|
||||
contactsResult.map(async (contact) => {
|
||||
const { contactId, attributes } = contact;
|
||||
|
||||
const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays);
|
||||
const surveyUrlResult = await getContactSurveyLink(contactId, surveyId, expirationDays);
|
||||
|
||||
if (!surveyUrlResult.ok) {
|
||||
logger.error(
|
||||
@@ -522,7 +522,8 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string,
|
||||
expirationDays,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
);
|
||||
|
||||
return contactLinks;
|
||||
const filteredContactLinks = contactLinks.filter(Boolean);
|
||||
return filteredContactLinks;
|
||||
};
|
||||
|
||||
@@ -313,32 +313,32 @@ export async function PreviewEmailTemplate({
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Section className="mx-0">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-1 mr-1 inline-block h-[140px] w-[220px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
<QuestionHeader headline={headline} subheader={subheader} className="mr-8" />
|
||||
<Section className="mx-0 mt-4">
|
||||
{firstQuestion.choices.map((choice) =>
|
||||
firstQuestion.allowMulti ? (
|
||||
<Img
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
key={choice.id}
|
||||
src={choice.imageUrl}
|
||||
/>
|
||||
) : (
|
||||
<Link
|
||||
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
|
||||
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
|
||||
key={choice.id}
|
||||
target="_blank">
|
||||
<Img className="rounded-custom h-full w-full" src={choice.imageUrl} />
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionTypeEnum.Cal:
|
||||
return (
|
||||
<EmailTemplateWrapper styling={styling} surveyUrl={url}>
|
||||
|
||||
@@ -11,30 +11,30 @@ interface TriggerCheckboxGroupProps {
|
||||
allowChanges: boolean;
|
||||
}
|
||||
|
||||
const triggers: {
|
||||
title: string;
|
||||
value: PipelineTriggers;
|
||||
}[] = [
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_created",
|
||||
value: "responseCreated",
|
||||
},
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_updated",
|
||||
value: "responseUpdated",
|
||||
},
|
||||
{
|
||||
title: "environments.integrations.webhooks.response_finished",
|
||||
value: "responseFinished",
|
||||
},
|
||||
];
|
||||
|
||||
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
selectedTriggers,
|
||||
onCheckboxChange,
|
||||
allowChanges,
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const triggers: {
|
||||
title: string;
|
||||
value: PipelineTriggers;
|
||||
}[] = [
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_created"),
|
||||
value: "responseCreated",
|
||||
},
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_updated"),
|
||||
value: "responseUpdated",
|
||||
},
|
||||
{
|
||||
title: t("environments.integrations.webhooks.response_finished"),
|
||||
value: "responseFinished",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
@@ -58,7 +58,7 @@ export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
}}
|
||||
disabled={!allowChanges}
|
||||
/>
|
||||
<span className="ml-2">{t(trigger.title)}</span>
|
||||
<span className="ml-2">{trigger.title}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -95,6 +95,5 @@ describe("getOrganizationAccessKeyDisplayName", () => {
|
||||
test("returns tolgee string for other keys", () => {
|
||||
const t = vi.fn((k) => k);
|
||||
expect(getOrganizationAccessKeyDisplayName("otherKey", t)).toBe("otherKey");
|
||||
expect(t).toHaveBeenCalledWith("otherKey");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,6 @@ export const getOrganizationAccessKeyDisplayName = (key: string, t: TFnType) =>
|
||||
case "accessControl":
|
||||
return t("environments.project.api_keys.access_control");
|
||||
default:
|
||||
return t(key);
|
||||
return key;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -16,14 +16,6 @@ import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
project: Project;
|
||||
environmentId: string;
|
||||
@@ -40,6 +32,14 @@ type EditPlacementFormValues = z.infer<typeof ZProjectPlacementInput>;
|
||||
|
||||
export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const placements = [
|
||||
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
|
||||
{ name: t("common.top_right"), value: "topRight", disabled: false },
|
||||
{ name: t("common.top_left"), value: "topLeft", disabled: false },
|
||||
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
|
||||
{ name: t("common.centered_modal"), value: "center", disabled: false },
|
||||
];
|
||||
const form = useForm<EditPlacementFormValues>({
|
||||
defaultValues: {
|
||||
placement: project.placement,
|
||||
@@ -102,7 +102,7 @@ export const EditPlacementForm = ({ project, isReadOnly }: EditPlacementProps) =
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={`text-slate-900 ${isReadOnly ? "cursor-not-allowed opacity-50" : "cursor-pointer"}`}>
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,16 +12,16 @@ import { RadioGroup, RadioGroupItem } from "@/modules/ui/components/radio-group"
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
const placements = [
|
||||
{ name: "common.bottom_right", value: "bottomRight", disabled: false },
|
||||
{ name: "common.top_right", value: "topRight", disabled: false },
|
||||
{ name: "common.top_left", value: "topLeft", disabled: false },
|
||||
{ name: "common.bottom_left", value: "bottomLeft", disabled: false },
|
||||
{ name: "common.centered_modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
export const ProjectLookSettingsLoading = () => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const placements = [
|
||||
{ name: t("common.bottom_right"), value: "bottomRight", disabled: false },
|
||||
{ name: t("common.top_right"), value: "topRight", disabled: false },
|
||||
{ name: t("common.top_left"), value: "topLeft", disabled: false },
|
||||
{ name: t("common.bottom_left"), value: "bottomLeft", disabled: false },
|
||||
{ name: t("common.centered_modal"), value: "center", disabled: false },
|
||||
];
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
@@ -140,7 +140,7 @@ export const ProjectLookSettingsLoading = () => {
|
||||
className={cn(
|
||||
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
|
||||
)}>
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -62,7 +62,7 @@ export const TemplateFilters = ({
|
||||
: "bg-white text-slate-700 hover:bg-slate-100 focus:scale-105 focus:bg-slate-100 focus:outline-none focus:ring-0",
|
||||
"rounded border border-slate-800 px-2 py-1 text-xs transition-all duration-150"
|
||||
)}>
|
||||
{t(filter.label)}
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
|
||||
const labels = channels
|
||||
.map((channel) => {
|
||||
const label = getLabel(channel);
|
||||
if (label) return t(label);
|
||||
if (label) return label;
|
||||
return undefined;
|
||||
})
|
||||
.filter((label): label is string => !!label)
|
||||
@@ -78,12 +78,12 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
// if user selects an industry e.g. eCommerce than the tag should not say "Multiple industries" anymore but "E-Commerce".
|
||||
if (selectedFilter[1] !== null) {
|
||||
const industry = getIndustryMapping(t).find((industry) => industry.value === selectedFilter[1]);
|
||||
if (industry) return t(industry.label);
|
||||
if (industry) return industry.label;
|
||||
}
|
||||
if (!industries || industries.length === 0) return undefined;
|
||||
return industries.length > 1
|
||||
? t("environments.surveys.templates.multiple_industries")
|
||||
: t(getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label ?? "");
|
||||
: getIndustryMapping(t).find((industry) => industry.value === industries[0])?.label;
|
||||
};
|
||||
|
||||
const industryTag = useMemo(
|
||||
@@ -93,7 +93,7 @@ export const TemplateTags = ({ template, selectedFilter }: TemplateTagsProps) =>
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{t(roleTag ?? "")}</div>
|
||||
<div className={cn("rounded border px-1.5 py-0.5 text-xs", roleBasedStyling)}>{roleTag}</div>
|
||||
{industryTag && (
|
||||
<div
|
||||
className={cn("rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 text-xs text-slate-500")}>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const Placement = ({
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label htmlFor={placement.value} className="text-slate-900">
|
||||
{t(placement.name)}
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -166,9 +166,9 @@ export const RecontactOptionsCard = ({
|
||||
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-700">{t(option.name)}</p>
|
||||
<p className="font-semibold text-slate-700">{option.name}</p>
|
||||
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{t(option.description)}</p>
|
||||
<p className="mt-2 text-xs font-normal text-slate-600">{option.description}</p>
|
||||
</div>
|
||||
</Label>
|
||||
{option.id === "displaySome" && localSurvey.displayOption === "displaySome" && (
|
||||
|
||||
@@ -420,6 +420,8 @@ export const ResponseOptionsCard = ({
|
||||
/>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Protect Survey with Pin */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
@@ -442,6 +444,7 @@ export const ResponseOptionsCard = ({
|
||||
defaultValue={localSurvey.pin ? localSurvey.pin : undefined}
|
||||
onKeyDown={handleSurveyPinInputKeyDown}
|
||||
onChange={(e) => handleProtectSurveyPinChange(e.target.value)}
|
||||
maxLength={4}
|
||||
/>
|
||||
{verifyProtectWithPinError && (
|
||||
<p className="pt-1 text-sm text-red-700">{verifyProtectWithPinError}</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendFollowUpEmail } from "@/modules/survey/follow-ups/lib/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { FollowUpResult, FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
@@ -16,11 +17,6 @@ import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 50, // max 50 calls per org per hour
|
||||
});
|
||||
|
||||
const evaluateFollowUp = async (
|
||||
followUp: TSurveyFollowUp,
|
||||
survey: TSurvey,
|
||||
@@ -191,7 +187,7 @@ export const sendFollowUpsForResponse = async (
|
||||
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(organization.id);
|
||||
await applyRateLimit(rateLimitConfigs.actions.surveyFollowUp, organization.id);
|
||||
} catch {
|
||||
return err({
|
||||
code: FollowUpSendError.RATE_LIMIT_EXCEEDED,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const LinkSurvey = ({
|
||||
|
||||
const prefillValue = getPrefillValue(survey, searchParams, languageCode);
|
||||
|
||||
const [autoFocus, setAutofocus] = useState(false);
|
||||
const [autoFocus, setAutoFocus] = useState(false);
|
||||
const hasFinishedSingleUseResponse = useMemo(() => {
|
||||
if (singleUseResponse?.finished) {
|
||||
return true;
|
||||
@@ -91,7 +91,7 @@ export const LinkSurvey = ({
|
||||
// Not in an iframe, enable autofocus on input fields.
|
||||
useEffect(() => {
|
||||
if (window.self === window.top) {
|
||||
setAutofocus(true);
|
||||
setAutoFocus(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only run once
|
||||
}, []);
|
||||
@@ -121,7 +121,7 @@ export const LinkSurvey = ({
|
||||
return <SurveyLinkUsed singleUseMessage={survey.singleUse} project={project} />;
|
||||
}
|
||||
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified") {
|
||||
if (survey.isVerifyEmailEnabled && emailVerificationStatus !== "verified" && !isPreview) {
|
||||
if (emailVerificationStatus === "fishy") {
|
||||
return (
|
||||
<VerifyEmail
|
||||
|
||||
@@ -64,7 +64,7 @@ export const renderSurvey = async ({
|
||||
return (
|
||||
<SurveyInactive
|
||||
status={survey.status}
|
||||
surveyClosedMessage={survey.surveyClosedMessage ? survey.surveyClosedMessage : undefined}
|
||||
surveyClosedMessage={survey.surveyClosedMessage ?? undefined}
|
||||
project={project || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getExistingContactResponse } from "@/modules/survey/link/lib/data";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -14,6 +15,7 @@ interface ContactSurveyPageProps {
|
||||
jwt: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
suId?: string;
|
||||
verify?: string;
|
||||
lang?: string;
|
||||
embed?: string;
|
||||
@@ -46,9 +48,10 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise<M
|
||||
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
const searchParams = await props.searchParams;
|
||||
const params = await props.params;
|
||||
|
||||
const t = await getTranslate();
|
||||
const { jwt } = params;
|
||||
const { preview } = searchParams;
|
||||
const { preview, suId } = searchParams;
|
||||
|
||||
const result = verifyContactSurveyToken(jwt);
|
||||
if (!result.ok) {
|
||||
@@ -62,6 +65,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
// So we show SurveyInactive without project data (shows branding by default for backward compatibility)
|
||||
return <SurveyInactive status="link invalid" />;
|
||||
}
|
||||
|
||||
const { surveyId, contactId } = result.data;
|
||||
|
||||
const existingResponse = await getExistingContactResponse(surveyId, contactId)();
|
||||
@@ -81,10 +85,26 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const isSingleUseSurvey = survey?.singleUse?.enabled;
|
||||
const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted;
|
||||
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
return renderSurvey({
|
||||
survey,
|
||||
searchParams,
|
||||
contactId,
|
||||
isPreview,
|
||||
singleUseId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { getEmailVerificationDetails } from "./helper";
|
||||
import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper";
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
verifyTokenForLinkSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/singleUseSurveys", () => ({
|
||||
validateSurveySingleUseId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getEmailVerificationDetails", () => {
|
||||
const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey);
|
||||
const testSurveyId = "survey-123";
|
||||
@@ -54,3 +59,82 @@ describe("getEmailVerificationDetails", () => {
|
||||
expect(mockedVerifyTokenForLinkSurvey).toHaveBeenCalledWith(testToken, testSurveyId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkAndValidateSingleUseId", () => {
|
||||
const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("returns null when no suid is provided", () => {
|
||||
const result = checkAndValidateSingleUseId();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns null when suid is empty string", () => {
|
||||
const result = checkAndValidateSingleUseId("");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is false", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid, false);
|
||||
|
||||
expect(result).toBe(testSuid);
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => {
|
||||
const testSuid = "plain-suid-123";
|
||||
const result = checkAndValidateSingleUseId(testSuid);
|
||||
|
||||
expect(result).toBe(testSuid);
|
||||
expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns validated suid when isEncrypted is true and validation succeeds", () => {
|
||||
const encryptedSuid = "encrypted-suid-123";
|
||||
const validatedSuid = "validated-suid-456";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(validatedSuid);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBe(validatedSuid);
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns undefined", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(undefined);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns empty string", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce("");
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
|
||||
test("returns null when isEncrypted is true and validation returns null", () => {
|
||||
const encryptedSuid = "invalid-encrypted-suid";
|
||||
mockedValidateSurveySingleUseId.mockReturnValueOnce(null as any);
|
||||
|
||||
const result = checkAndValidateSingleUseId(encryptedSuid, true);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { verifyTokenForLinkSurvey } from "@/lib/jwt";
|
||||
|
||||
interface emailVerificationDetails {
|
||||
@@ -25,3 +26,15 @@ export const getEmailVerificationDetails = async (
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => {
|
||||
if (!suid?.trim()) return null;
|
||||
|
||||
if (isEncrypted) {
|
||||
const validatedSingleUseId = validateSurveySingleUseId(suid);
|
||||
if (!validatedSingleUseId) return null;
|
||||
return validatedSingleUseId;
|
||||
}
|
||||
|
||||
return suid;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,16 @@ import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { LinkSurveyPage, generateMetadata } from "./page";
|
||||
|
||||
// Mock server-side constants to prevent client-side access
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_RECAPTCHA_CONFIGURED: false,
|
||||
RECAPTCHA_SITE_KEY: "test-key",
|
||||
IMPRINT_URL: "https://example.com/imprint",
|
||||
PRIVACY_URL: "https://example.com/privacy",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
notFound: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive";
|
||||
import { renderSurvey } from "@/modules/survey/link/components/survey-renderer";
|
||||
import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data";
|
||||
import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper";
|
||||
import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project";
|
||||
import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata";
|
||||
import type { Metadata } from "next";
|
||||
@@ -60,23 +60,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => {
|
||||
let singleUseId: string | undefined = undefined;
|
||||
|
||||
if (isSingleUseSurvey) {
|
||||
// check if the single use id is present for single use surveys
|
||||
if (!suId) {
|
||||
const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
|
||||
// if encryption is enabled, validate the single use id
|
||||
let validatedSingleUseId: string | undefined = undefined;
|
||||
if (isSingleUseSurveyEncrypted) {
|
||||
validatedSingleUseId = validateSurveySingleUseId(suId);
|
||||
if (!validatedSingleUseId) {
|
||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||
return <SurveyInactive status="link invalid" project={project ?? undefined} />;
|
||||
}
|
||||
}
|
||||
// if encryption is disabled, use the suId as is
|
||||
singleUseId = validatedSingleUseId ?? suId;
|
||||
singleUseId = validatedSingleUseId;
|
||||
}
|
||||
|
||||
let singleUseResponse;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
import { TSortOption } from "@formbricks/types/surveys/types";
|
||||
import { SortOption } from "./sort-option";
|
||||
|
||||
// Mock dependencies
|
||||
@@ -21,7 +20,7 @@ vi.mock("@tolgee/react", () => ({
|
||||
describe("SortOption", () => {
|
||||
const mockOption: TSortOption = {
|
||||
label: "test.sort.option",
|
||||
value: "testValue",
|
||||
value: "createdAt",
|
||||
};
|
||||
|
||||
const mockHandleSortChange = vi.fn();
|
||||
@@ -32,14 +31,14 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with the option label", () => {
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
expect(screen.getByText("test.sort.option")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-menu-item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("applies correct styling when option is selected", () => {
|
||||
render(<SortOption option={mockOption} sortBy="testValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
|
||||
expect(circleIndicator).toHaveClass("bg-brand-dark");
|
||||
@@ -47,11 +46,10 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("applies correct styling when option is not selected", () => {
|
||||
render(
|
||||
<SortOption option={mockOption} sortBy="differentValue" handleSortChange={mockHandleSortChange} />
|
||||
);
|
||||
render(<SortOption option={mockOption} sortBy="updatedAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
const circleIndicator = screen.getByTestId("dropdown-menu-item").querySelector("span");
|
||||
expect(circleIndicator).toHaveClass("border-white");
|
||||
expect(circleIndicator).not.toHaveClass("bg-brand-dark");
|
||||
expect(circleIndicator).not.toHaveClass("outline-brand-dark");
|
||||
});
|
||||
@@ -59,7 +57,7 @@ describe("SortOption", () => {
|
||||
test("calls handleSortChange when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
await user.click(screen.getByTestId("dropdown-menu-item"));
|
||||
expect(mockHandleSortChange).toHaveBeenCalledTimes(1);
|
||||
@@ -67,7 +65,7 @@ describe("SortOption", () => {
|
||||
});
|
||||
|
||||
test("translates the option label", () => {
|
||||
render(<SortOption option={mockOption} sortBy="otherValue" handleSortChange={mockHandleSortChange} />);
|
||||
render(<SortOption option={mockOption} sortBy="createdAt" handleSortChange={mockHandleSortChange} />);
|
||||
|
||||
// The mock for useTranslate returns the key itself, so we're checking if translation was attempted
|
||||
expect(screen.getByText(mockOption.label)).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownMenuItem } from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TSortOption, TSurveyFilters } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface SortOptionProps {
|
||||
@@ -11,7 +10,6 @@ interface SortOptionProps {
|
||||
}
|
||||
|
||||
export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={option.label}
|
||||
@@ -22,7 +20,7 @@ export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
|
||||
<p className="font-normal text-white">{t(option.label)}</p>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -219,16 +219,18 @@ export const SurveyDropDownMenu = ({
|
||||
{t("common.preview_survey")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => handleCopyLink(e)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
{!survey.singleUse?.enabled && (
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="copy-link"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => handleCopyLink(e)}>
|
||||
<LinkIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy_link")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
|
||||
@@ -4,11 +4,6 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TFilterOption } from "@formbricks/types/surveys/types";
|
||||
import { SurveyFilterDropdown } from "./survey-filter-dropdown";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/checkbox", () => ({
|
||||
Checkbox: ({ checked, className }) => (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { TFilterOption } from "@formbricks/types/surveys/types";
|
||||
|
||||
@@ -30,7 +29,6 @@ export const SurveyFilterDropdown = ({
|
||||
isOpen,
|
||||
toggleDropdown,
|
||||
}: SurveyFilterDropdownProps) => {
|
||||
const { t } = useTranslate();
|
||||
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
|
||||
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
|
||||
|
||||
@@ -56,7 +54,7 @@ export const SurveyFilterDropdown = ({
|
||||
checked={selectedOptions.includes(option.value)}
|
||||
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
|
||||
/>
|
||||
<p className="font-normal text-white">{t(option.label)}</p>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
@@ -183,7 +183,7 @@ export const SurveyFilters = ({
|
||||
<span className="text-sm">
|
||||
{t("common.sort_by")}:{" "}
|
||||
{getSortOptions(t).find((option) => option.value === sortBy)
|
||||
? t(getSortOptions(t).find((option) => option.value === sortBy)?.label ?? "")
|
||||
? getSortOptions(t).find((option) => option.value === sortBy)?.label
|
||||
: ""}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<>
|
||||
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
|
||||
<div className="lowercase">
|
||||
{selectedRowCount} {t(`common.${type}s`)} {t("common.selected")}
|
||||
{`${selectedRowCount} ${type === "response" ? t("common.responses") : t("common.contacts")} ${t("common.selected")}`}
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
@@ -146,7 +146,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<DeleteDialog
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
deleteWhat={t(`common.${type}`)}
|
||||
deleteWhat={type === "response" ? t("common.responses") : t("common.contacts")}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
text={deleteDialogText}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ToolbarPlugin } from "./toolbar-plugin";
|
||||
- bold
|
||||
- italic
|
||||
- link
|
||||
- underline
|
||||
*/
|
||||
export type TextEditorProps = {
|
||||
getText: () => string;
|
||||
|
||||
@@ -147,6 +147,7 @@ vi.mock("lucide-react", () => ({
|
||||
Bold: () => <span data-testid="bold-icon">Bold</span>,
|
||||
Italic: () => <span data-testid="italic-icon">Italic</span>,
|
||||
Link: () => <span data-testid="link-icon">Link</span>,
|
||||
Underline: () => <span data-testid="underline-icon">Underline</span>,
|
||||
ChevronDownIcon: () => <span data-testid="chevron-icon">ChevronDown</span>,
|
||||
}));
|
||||
|
||||
@@ -186,6 +187,7 @@ describe("ToolbarPlugin", () => {
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -218,20 +220,57 @@ describe("ToolbarPlugin", () => {
|
||||
});
|
||||
|
||||
test("excludes toolbar items when specified", () => {
|
||||
render(
|
||||
const { rerender } = render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["bold", "italic"]}
|
||||
excludedToolbarItems={["bold", "italic", "underline"]}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should not render bold and italic buttons but should render link
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("link-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
|
||||
// Rerender with different excluded items
|
||||
rerender(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("bold-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("italic-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("underline-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("excludes all toolbar items when specified", () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
excludedToolbarItems={["blockType", "bold", "italic", "underline", "link"]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("dropdown-menu")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("bold-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("italic-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("underline-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("link-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles firstRender and updateTemplate props", () => {
|
||||
@@ -253,4 +292,122 @@ describe("ToolbarPlugin", () => {
|
||||
// the component renders without errors when these props are provided
|
||||
expect(screen.getByTestId("dropdown-menu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("User Interactions", () => {
|
||||
test("dispatches bold format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const boldIcon = screen.getByTestId("bold-icon");
|
||||
const boldButton = boldIcon.parentElement;
|
||||
expect(boldButton).toBeInTheDocument();
|
||||
expect(boldButton).not.toBeNull();
|
||||
await userEvent.click(boldButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "bold");
|
||||
});
|
||||
|
||||
test("dispatches italic format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const italicIcon = screen.getByTestId("italic-icon");
|
||||
const italicButton = italicIcon.parentElement;
|
||||
expect(italicButton).toBeInTheDocument();
|
||||
expect(italicButton).not.toBeNull();
|
||||
await userEvent.click(italicButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "italic");
|
||||
});
|
||||
|
||||
test("dispatches underline format command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const underlineIcon = screen.getByTestId("underline-icon");
|
||||
const underlineButton = underlineIcon.parentElement;
|
||||
expect(underlineButton).toBeInTheDocument();
|
||||
expect(underlineButton).not.toBeNull();
|
||||
await userEvent.click(underlineButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("formatText", "underline");
|
||||
});
|
||||
|
||||
test("dispatches link command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const linkIcon = screen.getByTestId("link-icon");
|
||||
const linkButton = linkIcon.parentElement;
|
||||
expect(linkButton).toBeInTheDocument();
|
||||
expect(linkButton).not.toBeNull();
|
||||
await userEvent.click(linkButton!);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("toggleLink", {
|
||||
url: "https://",
|
||||
});
|
||||
});
|
||||
|
||||
test("dispatches numbered list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const numberedListButton = screen.getAllByTestId("button")[1]; // ol
|
||||
await userEvent.click(numberedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertOrderedList", undefined);
|
||||
});
|
||||
|
||||
test("dispatches bulleted list command on click", async () => {
|
||||
render(
|
||||
<ToolbarPlugin
|
||||
getText={() => "Sample text"}
|
||||
setText={vi.fn()}
|
||||
editable={true}
|
||||
container={document.createElement("div")}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-menu-trigger");
|
||||
await userEvent.click(dropdownTrigger);
|
||||
|
||||
const bulletedListButton = screen.getAllByTestId("button")[2]; // ul
|
||||
await userEvent.click(bulletedListButton);
|
||||
|
||||
expect(mockEditor.dispatchCommand).toHaveBeenCalledWith("insertUnorderedList", undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,12 @@ import {
|
||||
$getSelection,
|
||||
$insertNodes,
|
||||
$isRangeSelection,
|
||||
COMMAND_PRIORITY_CRITICAL,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from "lexical";
|
||||
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
|
||||
import { Bold, ChevronDownIcon, Italic, Link } from "lucide-react";
|
||||
import { Bold, ChevronDownIcon, Italic, Link, Underline } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { AddVariablesDropdown } from "./add-variables-dropdown";
|
||||
@@ -235,6 +236,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
const [isLink, setIsLink] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
|
||||
// save ref to setText to use it in event listeners safely
|
||||
const setText = useRef<any>(props.setText);
|
||||
@@ -334,7 +336,7 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
}
|
||||
setIsBold(selection.hasFormat("bold"));
|
||||
setIsItalic(selection.hasFormat("italic"));
|
||||
|
||||
setIsUnderline(selection.hasFormat("underline"));
|
||||
const node = getSelectedNode(selection);
|
||||
const parent = node.getParent();
|
||||
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
||||
@@ -459,95 +461,94 @@ export const ToolbarPlugin = (props: TextEditorProps & { container: HTMLElement
|
||||
|
||||
if (!props.editable) return <></>;
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: "bold",
|
||||
icon: Bold,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"),
|
||||
active: isBold,
|
||||
},
|
||||
{
|
||||
key: "italic",
|
||||
icon: Italic,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"),
|
||||
active: isItalic,
|
||||
},
|
||||
{
|
||||
key: "underline",
|
||||
icon: Underline,
|
||||
onClick: () => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"),
|
||||
active: isUnderline,
|
||||
},
|
||||
{
|
||||
key: "link",
|
||||
icon: Link,
|
||||
onClick: insertLink,
|
||||
active: isLink,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="toolbar flex" ref={toolbarRef}>
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<span className={"icon" + blockType} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={"icon block-type " + key} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
|
||||
<>
|
||||
{!props.excludedToolbarItems?.includes("bold") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
||||
}}
|
||||
className={isBold ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Bold />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("italic") && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
||||
}}
|
||||
className={isItalic ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Italic />
|
||||
</Button>
|
||||
)}
|
||||
{!props.excludedToolbarItems?.includes("link") && (
|
||||
{!props.excludedToolbarItems?.includes("blockType") && supportedBlockTypes.has(blockType) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="text-subtle">
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={insertLink}
|
||||
className={isLink ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Link />
|
||||
</Button>
|
||||
{isLink ? (
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<span className={cn("icon", blockType)} />
|
||||
<span className="text text-default hidden sm:flex">
|
||||
{blockTypeToBlockName[blockType as keyof BlockType]}
|
||||
</span>
|
||||
<ChevronDownIcon className="text-default ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{Object.keys(blockTypeToBlockName).map((key) => {
|
||||
return (
|
||||
<DropdownMenuItem key={key}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => format(key)}
|
||||
className={cn(
|
||||
"w-full rounded-none focus:ring-0",
|
||||
blockType === key ? "bg-subtle w-full" : ""
|
||||
)}>
|
||||
<>
|
||||
<span className={cn("icon block-type", key)} />
|
||||
<span>{blockTypeToBlockName[key]}</span>
|
||||
</>
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
{items.map(({ key, icon: Icon, onClick, active }) =>
|
||||
!props.excludedToolbarItems?.includes(key) ? (
|
||||
<Button
|
||||
key={key}
|
||||
variant="ghost"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={active ? "bg-subtle active-button" : "inactive-button"}>
|
||||
<Icon />
|
||||
</Button>
|
||||
) : null
|
||||
)}
|
||||
{isLink &&
|
||||
!props.excludedToolbarItems?.includes("link") &&
|
||||
createPortal(<FloatingLinkEditor editor={editor} />, props.container ?? document.body)}
|
||||
|
||||
{props.variables && (
|
||||
<div className="ml-auto">
|
||||
<AddVariablesDropdown
|
||||
addVariable={addVariable}
|
||||
isTextEditor={true}
|
||||
variables={props.variables || []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,5 +20,6 @@ export const exampleTheme = {
|
||||
text: {
|
||||
bold: "fb-editor-text-bold",
|
||||
italic: "fb-editor-text-italic",
|
||||
underline: "fb-editor-text-underline",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
font-style: italic !important;
|
||||
}
|
||||
|
||||
.fb-editor-text-underline {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.fb-editor-link {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@ export const InputCombobox = ({
|
||||
)}
|
||||
<CommandList className="m-1">
|
||||
<CommandEmpty className="mx-2 my-0 text-xs font-semibold text-slate-500">
|
||||
{emptyDropdownText ? t(emptyDropdownText) : t("environments.surveys.edit.no_option_found")}
|
||||
{emptyDropdownText ?? t("environments.surveys.edit.no_option_found")}
|
||||
</CommandEmpty>
|
||||
{options && options.length > 0 && (
|
||||
<CommandGroup>
|
||||
|
||||
@@ -79,7 +79,6 @@
|
||||
"@tolgee/format-icu": "6.2.5",
|
||||
"@tolgee/react": "6.2.5",
|
||||
"@ungap/structured-clone": "1.3.0",
|
||||
"@unkey/ratelimit": "0.5.5",
|
||||
"@vercel/functions": "2.0.2",
|
||||
"@vercel/og": "0.6.8",
|
||||
"bcryptjs": "3.0.2",
|
||||
|
||||
@@ -104,7 +104,7 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
||||
<Step title="Scroll to the bottom and copy the IDP metadata">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/idp-metadata.webp" />
|
||||
</Step>
|
||||
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` directory" />
|
||||
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` (use `formbricks/apps/web/saml-connection` for development) directory" />
|
||||
</Steps>
|
||||
|
||||
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.
|
||||
|
||||
@@ -85,7 +85,7 @@ To configure SAML SSO in Formbricks, follow these steps:
|
||||
</Step>
|
||||
|
||||
<Step title="Metadata Setup">
|
||||
Create a file called `connection.xml` in your self-hosted Formbricks instance's `formbricks/saml-connection` directory and paste the XML metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
|
||||
Create a file called `connection.xml` in your self-hosted Formbricks instance's `formbricks/saml-connection` (use `formbricks/apps/web/saml-connection` for development) directory and paste the XML metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
|
||||
</Step>
|
||||
|
||||
<Step title="Restart Formbricks">
|
||||
|
||||
@@ -35,6 +35,10 @@ Formbricks supports a powerful domain separation feature that allows you to serv
|
||||
- Restrict admin functionality to the private domain
|
||||
- Ensure authentication and sensitive operations only occur on the private domain
|
||||
|
||||
<Warning>
|
||||
Publicly available images uploaded before the domain split (logos, images in Picture Select questions, etc.) will continue to be served from the Private Domain. **To protect your Private Domain, please reupload public images after the successful setup of the domain split.**
|
||||
</Warning>
|
||||
|
||||
### Why Use Domain Split?
|
||||
|
||||
- **Enhanced Security**: Separate public-facing surveys from your admin interface
|
||||
@@ -145,4 +149,13 @@ If `PUBLIC_URL` is not set:
|
||||
- The system behaves as a single domain setup
|
||||
- No domain separation occurs
|
||||
|
||||
### Reverting to a Single Domain
|
||||
While it's possible to revert to a Single Domain setup, please take into account the following:
|
||||
|
||||
1. All image links and uploaded files are stored in the database, so they will continue to link to the Public Domain **hence the links will break.** You'll need to reupload the images, like you did when you set up the domain split.
|
||||
2. Any survey link shared to with the Public Domain **will break.**
|
||||
3. API calls to the Public Domain **will break**, unless updated.
|
||||
|
||||
As of now, Formbricks does not provide an automated migration between domain setups.
|
||||
|
||||
If you have any questions or require help, feel free to reach out to us on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions).
|
||||
|
||||
@@ -54,7 +54,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| STRIPE_SECRET_KEY | Secret key for Stripe integration. | optional | |
|
||||
| STRIPE_WEBHOOK_SECRET | Webhook secret for Stripe integration. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry if set to 1. | optional | |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | #64748b |
|
||||
| DEFAULT_ORGANIZATION_ID | Automatically assign new users to a specific organization when joining | optional | |
|
||||
| OIDC_DISPLAY_NAME | Display name for Custom OpenID Connect Provider | optional | |
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
@@ -62,7 +62,6 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have .well-known configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | RS256 |
|
||||
| OPENTELEMETRY_LISTENER_URL | URL for OpenTelemetry listener inside Formbricks. | optional | |
|
||||
| UNKEY_ROOT_KEY | Key for the [Unkey](https://www.unkey.com/) service. This is used for Rate Limiting for management API. | optional | |
|
||||
| PROMETHEUS_ENABLED | Enables Prometheus metrics if set to 1. | optional | |
|
||||
| PROMETHEUS_EXPORTER_PORT | Port for Prometheus metrics. | optional | 9090 |
|
||||
| DOCKER_CRON_ENABLED | Controls whether cron jobs run in the Docker image. Set to 0 to disable (useful for cluster setups). | optional | 1 |
|
||||
|
||||
18
packages/js-core/.prettierrc.cjs
Normal file
18
packages/js-core/.prettierrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
// packages/js-core/.prettierrc.cjs
|
||||
const base = require("../config-prettier/prettier-preset");
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
plugins: [
|
||||
"@trivago/prettier-plugin-sort-imports",
|
||||
],
|
||||
|
||||
importOrder: [
|
||||
"^vitest$", // 1️⃣ vitest first
|
||||
"<THIRD_PARTY_MODULES>", // 2️⃣ then other externals
|
||||
"^@/.*$", // 3️⃣ then anything under @/
|
||||
"^\\.\\/__mocks__\\/.*$", // 4️⃣ then anything under ./__mocks__/
|
||||
"^[./]", // 5️⃣ finally all other relative imports
|
||||
],
|
||||
importOrderSortSpecifiers: true,
|
||||
};
|
||||
@@ -44,6 +44,7 @@
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@trivago/prettier-plugin-sort-imports": "5.2.2",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@vitest/coverage-v8": "3.1.3",
|
||||
"terser": "5.39.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { wrapThrowsAsync } from "@/lib/common/utils";
|
||||
import { ApiResponse, ApiSuccessResponse, CreateOrUpdateUserResponse } from "@/types/api";
|
||||
import { TEnvironmentState } from "@/types/config";
|
||||
import { ApiErrorResponse, Result, err, ok } from "@/types/error";
|
||||
import { type ApiResponse, type ApiSuccessResponse, type CreateOrUpdateUserResponse } from "@/types/api";
|
||||
import { type TEnvironmentState } from "@/types/config";
|
||||
import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
|
||||
|
||||
export const makeRequest = async <T>(
|
||||
appUrl: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// api.test.ts
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ApiClient, makeRequest } from "@/lib/common/api";
|
||||
import type { TEnvironmentState } from "@/types/config";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock fetch
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { CommandQueue, CommandType } from "@/lib/common/command-queue";
|
||||
import { checkSetup } from "@/lib/common/status";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type Result } from "@/types/error";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the setup module so we can control checkSetup()
|
||||
vi.mock("@/lib/common/status", () => ({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- required for mocking */
|
||||
// config.test.ts
|
||||
import { mockConfig } from "./__mocks__/config.mock";
|
||||
import { type Mock, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
|
||||
import type { TConfig, TConfigUpdateInput } from "@/types/config";
|
||||
import { type Mock, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mockConfig } from "./__mocks__/config.mock";
|
||||
|
||||
// Define mocks outside of any describe block
|
||||
const getItemMock = localStorage.getItem as unknown as Mock;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// event-listeners.test.ts
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
addCleanupEventListeners,
|
||||
addEventListeners,
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
import * as environmentState from "@/lib/environment/state";
|
||||
import * as pageUrlEventListeners from "@/lib/survey/no-code-action";
|
||||
import * as userState from "@/lib/user/state";
|
||||
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// 1) Mock all the imported dependencies
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// file-upload.test.ts
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { StorageAPI } from "@/lib/common/file-upload";
|
||||
import type { TUploadFileConfig } from "@/types/storage";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// A global fetch mock so we can capture fetch calls.
|
||||
// Alternatively, use `vi.stubGlobal("fetch", ...)`.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
// logger.test.ts
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// adjust import path as needed
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
|
||||
describe("Logger", () => {
|
||||
let logger: Logger;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- required for testing */
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { JS_LOCAL_STORAGE_KEY } from "@/lib/common/constants";
|
||||
import { addCleanupEventListeners, addEventListeners } from "@/lib/common/event-listeners";
|
||||
@@ -6,10 +7,10 @@ import { Logger } from "@/lib/common/logger";
|
||||
import { handleErrorOnFirstSetup, setup, tearDown } from "@/lib/common/setup";
|
||||
import { setIsSetup } from "@/lib/common/status";
|
||||
import { filterSurveys, isNowExpired } from "@/lib/common/utils";
|
||||
import type * as Utils from "@/lib/common/utils";
|
||||
import { fetchEnvironmentState } from "@/lib/environment/state";
|
||||
import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
|
||||
import { sendUpdatesToBackend } from "@/lib/user/update";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const setItemMock = localStorage.setItem as unknown as Mock;
|
||||
|
||||
@@ -50,8 +51,9 @@ vi.mock("@/lib/environment/state", () => ({
|
||||
|
||||
// 6) Mock filterSurveys
|
||||
vi.mock("@/lib/common/utils", async (importOriginal) => {
|
||||
const originalModule = await importOriginal<typeof Utils>();
|
||||
return {
|
||||
...(await importOriginal<typeof import("@/lib/common/utils")>()),
|
||||
...originalModule,
|
||||
filterSurveys: vi.fn(),
|
||||
isNowExpired: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { checkSetup, getIsSetup, setIsSetup } from "@/lib/common/status";
|
||||
|
||||
describe("checkSetup()", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TimeoutStack } from "@/lib/common/timeout-stack";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TimeoutStack } from "@/lib/common/timeout-stack";
|
||||
|
||||
// Using vitest, we don't need to manually declare globals
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// utils.test.ts
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
|
||||
import {
|
||||
checkUrlMatch,
|
||||
@@ -26,7 +27,6 @@ import type {
|
||||
TUserState,
|
||||
} from "@/types/config";
|
||||
import { type TActionClassNoCodeConfig, type TActionClassPageUrlRule } from "@/types/survey";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
|
||||
const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// state.test.ts
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ApiClient } from "@/lib/common/api";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
fetchEnvironmentState,
|
||||
} from "@/lib/environment/state";
|
||||
import type { TEnvironmentState } from "@/types/config";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the ApiClient so we can control environment.getEnvironmentState
|
||||
vi.mock("@/lib/common/api", () => ({
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { trackAction, trackCodeAction, trackNoCodeAction } from "@/lib/survey/action";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { triggerSurvey } from "@/lib/survey/widget";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
Config: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/unbound-method -- mock functions are unbound */
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { checkSetup } from "@/lib/common/status";
|
||||
import { TimeoutStack } from "@/lib/common/timeout-stack";
|
||||
@@ -17,8 +18,7 @@ import {
|
||||
removeScrollDepthListener,
|
||||
} from "@/lib/survey/no-code-action";
|
||||
import { setIsSurveyRunning } from "@/lib/survey/widget";
|
||||
import { TActionClassNoCodeConfig } from "@/types/survey";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { type TActionClassNoCodeConfig } from "@/types/survey";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
Config: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import type { TEnvironmentStateSurvey } from "@/types/config";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import type { TEnvironmentStateSurvey } from "@/types/config";
|
||||
|
||||
describe("SurveyStore", () => {
|
||||
let store: SurveyStore;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { filterSurveys, getLanguageCode, shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
|
||||
import { mockSurvey } from "@/lib/survey/tests/__mocks__/widget.mock";
|
||||
import * as widget from "@/lib/survey/widget";
|
||||
import { type TEnvironmentStateSurvey } from "@/types/config";
|
||||
import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
Config: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { setAttributes } from "@/lib/user/attribute";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
export const mockAttributes = {
|
||||
name: "John Doe",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
|
||||
import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockUserId = "user_123";
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
|
||||
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { Config } from "@/lib/common/config";
|
||||
import { Logger } from "@/lib/common/logger";
|
||||
import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
|
||||
import { sendUpdates } from "@/lib/user/update";
|
||||
import { UpdateQueue } from "@/lib/user/update-queue";
|
||||
import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/common/config", () => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user