mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 22:50:35 -06:00
Compare commits
44 Commits
v3.10.0
...
editor-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
639e25d679 | ||
|
|
f7e5ef96d2 | ||
|
|
745f5487e9 | ||
|
|
0e7f3adf53 | ||
|
|
342d2b1fc4 | ||
|
|
15279685f7 | ||
|
|
12aa959f50 | ||
|
|
9478946c7a | ||
|
|
8560bbf28b | ||
|
|
df7afe1b64 | ||
|
|
df52b60d61 | ||
|
|
65b051f0eb | ||
|
|
7678084061 | ||
|
|
022d33d06f | ||
|
|
4d157bf8dc | ||
|
|
9fcbe4e8c5 | ||
|
|
5aeb92eb4f | ||
|
|
00dfa629b5 | ||
|
|
3ca471b6a2 | ||
|
|
a525589186 | ||
|
|
59ed10398d | ||
|
|
25a86e31df | ||
|
|
7d6743a81a | ||
|
|
6616f62da5 | ||
|
|
a3cbc05e12 | ||
|
|
97095a627a | ||
|
|
910d257c56 | ||
|
|
0c0a008b28 | ||
|
|
9879458353 | ||
|
|
d44f1f3b4b | ||
|
|
c5d387a7e5 | ||
|
|
a6aacd5c55 | ||
|
|
57e7485564 | ||
|
|
42a38a6f47 | ||
|
|
34bb9c2127 | ||
|
|
6442b5e4aa | ||
|
|
dde5a55446 | ||
|
|
13e615a798 | ||
|
|
9c81961b0b | ||
|
|
c1a35e2d75 | ||
|
|
13415c75c2 | ||
|
|
300557a0e6 | ||
|
|
fcbb97010c | ||
|
|
6be46b16b2 |
6
.cursor/rules/testing.mdc
Normal file
6
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
@@ -172,7 +172,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Automatically assign new users to a specific organization and role within that organization
|
||||
# Insert an existing organization id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
|
||||
# (Role Management is an Enterprise feature)
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
@@ -212,5 +211,8 @@ UNKEY_ROOT_KEY=
|
||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# Disable the user management from UI
|
||||
# DISABLE_USER_MANAGEMENT=1
|
||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
84
.github/dependabot.yml
vendored
84
.github/dependabot.yml
vendored
@@ -1,84 +0,0 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||
directory: "/" # Root package.json
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
versioning-strategy: increase
|
||||
|
||||
# Apps directory packages
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/demo-react-native"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/storybook"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/web"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
# Packages directory
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/database"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/lib"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/types"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-eslint"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-prettier"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/config-typescript"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/js-core"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/surveys"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/packages/logger"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
5
.github/workflows/chromatic.yml
vendored
5
.github/workflows/chromatic.yml
vendored
@@ -10,6 +10,11 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
|
||||
2
.github/workflows/dependency-review.yml
vendored
2
.github/workflows/dependency-review.yml
vendored
@@ -24,4 +24,4 @@ jobs:
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
|
||||
23
.github/workflows/e2e.yml
vendored
23
.github/workflows/e2e.yml
vendored
@@ -11,6 +11,8 @@ on:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
# Add other secrets if necessary
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -23,7 +25,6 @@ permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -48,15 +49,17 @@ jobs:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
egress-policy: allow
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
@@ -75,7 +78,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -89,8 +92,18 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
if [ -z "$LICENSE_KEY" ]; then
|
||||
echo "::error::ENTERPRISE_LICENSE_KEY in .env is empty. Please check your secret configuration."
|
||||
exit 1
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
|
||||
5
.github/workflows/release-docker-github.yml
vendored
5
.github/workflows/release-docker-github.yml
vendored
@@ -20,18 +20,15 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
2
.github/workflows/release-helm-chart.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Helm chart to release'
|
||||
description: "The version of the Helm chart to release"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
|
||||
4
.github/workflows/semantic-pull-requests.yml
vendored
4
.github/workflows/semantic-pull-requests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
revert
|
||||
ossgg
|
||||
|
||||
- uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
- uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
# When the previous steps fails, the workflow would stop. By adding this
|
||||
# condition you can continue the execution with the populated error message.
|
||||
if: always() && (steps.lint_pr_title.outputs.error_message != null)
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@52423e01640425a022ef5fd42c6fb5f633a02728 # v2.9.1
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
2
.github/workflows/sonarqube.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
18
.github/workflows/terraform-plan-and-apply.yml
vendored
18
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,8 +1,8 @@
|
||||
name: 'Terraform'
|
||||
name: "Terraform"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: enable it back when migration is completed.
|
||||
# TODO: enable it back when migration is completed.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -14,14 +14,13 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
@@ -41,7 +40,7 @@ jobs:
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||
aws-region: "eu-central-1"
|
||||
@@ -71,7 +70,7 @@ jobs:
|
||||
working-directory: infra/terraform
|
||||
|
||||
- name: Post PR comment
|
||||
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
@@ -83,4 +82,3 @@ jobs:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: terraform apply .planfile
|
||||
working-directory: "infra/terraform"
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
|
||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -88,6 +88,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Page from "./page";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
@@ -89,6 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_AUTH_URL: "https://mock-oidc-auth-url.com",
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
@@ -97,23 +106,44 @@ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/compone
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/modules/ee/license-check/lib/utils");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual("react");
|
||||
return {
|
||||
...actual,
|
||||
cache: (fn: any) => fn,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Page component", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
test("redirects to login if no user session", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({ session: {}, organization: {} } as any);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||
expect(result).toBe("REDIRECT_STUB");
|
||||
});
|
||||
@@ -124,9 +154,17 @@ describe("Page component", () => {
|
||||
organization: {},
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
|
||||
expect(notFound).toHaveBeenCalled();
|
||||
expect(result).toBe("NOT_FOUND_STUB");
|
||||
});
|
||||
@@ -138,14 +176,21 @@ describe("Page component", () => {
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({ features: { isMultiOrgEnabled: true } } as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: true,
|
||||
features: { isMultiOrgEnabled: true },
|
||||
lastChecked: new Date(),
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -225,7 +225,7 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
|
||||
@@ -65,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -25,6 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -10,7 +9,7 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -49,7 +48,6 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(() => ({ isMember: true })), // Default to member for simplicity
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
@@ -176,7 +174,6 @@ describe("EnvironmentLayout", () => {
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(mockLicense);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
mockIsDevelopment = false;
|
||||
@@ -189,13 +186,19 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("renders correctly with default props", async () => {
|
||||
// Ensure the default mockLicense has isPendingDowngrade: false and active: false
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue({
|
||||
...mockLicense,
|
||||
isPendingDowngrade: false,
|
||||
active: false,
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -203,20 +206,31 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("main-navigation")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("top-control-bar")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child Content")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dev-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("limits-banner")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); // This should now pass
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -224,13 +238,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -238,17 +263,21 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("limits-banner")).toBeInTheDocument();
|
||||
expect(vi.mocked(getMonthlyActiveOrganizationPeopleCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
expect(vi.mocked(getMonthlyOrganizationResponseCount)).toHaveBeenCalledWith(mockOrganization.id);
|
||||
});
|
||||
|
||||
test("renders PendingDowngradeBanner when pending downgrade", async () => {
|
||||
// Ensure the license mock reflects the condition needed for the banner
|
||||
const pendingLicense = { ...mockLicense, isPendingDowngrade: true, active: true };
|
||||
vi.mocked(getEnterpriseLicense).mockResolvedValue(pendingLicense);
|
||||
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue(pendingLicense),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
@@ -256,12 +285,24 @@ describe("EnvironmentLayout", () => {
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.user_not_found"
|
||||
);
|
||||
@@ -269,6 +310,19 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if organization not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
@@ -276,13 +330,39 @@ describe("EnvironmentLayout", () => {
|
||||
|
||||
test("throws error if environment not found", async () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.environment_not_found"
|
||||
);
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any); // Simulate one of the promises failing
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"environments.projects_environments_organizations_not_found"
|
||||
);
|
||||
@@ -291,6 +371,19 @@ describe("EnvironmentLayout", () => {
|
||||
test("throws error if member has no project permission", async () => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({ isMember: true } as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
await expect(EnvironmentLayout({ environmentId: "env-1", session: mockSession })).rejects.toThrow(
|
||||
"common.project_permission_not_found"
|
||||
);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
|
||||
@@ -109,7 +109,7 @@ export const MainNavigation = ({
|
||||
|
||||
useEffect(() => {
|
||||
const toggleTextOpacity = () => {
|
||||
setIsTextVisible(isCollapsed ? true : false);
|
||||
setIsTextVisible(isCollapsed);
|
||||
};
|
||||
const timeoutId = setTimeout(toggleTextOpacity, 150);
|
||||
return () => clearTimeout(timeoutId);
|
||||
@@ -170,7 +170,7 @@ export const MainNavigation = ({
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions") || pathname?.includes("/actions"),
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -48,6 +48,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integration/service");
|
||||
|
||||
@@ -255,7 +255,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden 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">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
|
||||
@@ -31,6 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("AppConnectionPage Re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("GeneralSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("LanguagesPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectLookSettingsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TagsPage re-export", () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("ProjectTeams re-export", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
const mockGetOrganizationByEnvironmentId = vi.mocked(getOrganizationByEnvironmentId);
|
||||
|
||||
@@ -1,17 +1,80 @@
|
||||
"use server";
|
||||
|
||||
import {
|
||||
checkUserExistsByEmail,
|
||||
verifyUserPassword,
|
||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { deleteFile } from "@/lib/storage/service";
|
||||
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZUserUpdateInput } from "@formbricks/types/user";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
InvalidInputError,
|
||||
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
|
||||
});
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(ZUserUpdateInput.pick({ name: true, locale: true }))
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
return await updateUser(ctx.user.id, parsedInput);
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
|
||||
let payload: TUserUpdateInput = {
|
||||
name: parsedInput.name,
|
||||
locale: parsedInput.locale,
|
||||
};
|
||||
|
||||
if (inputEmail && ctx.user.email !== inputEmail) {
|
||||
// Check rate limit
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
|
||||
if (!parsedInput.password) {
|
||||
throw new AuthenticationError("Password is required to update email.");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyUserPassword(ctx.user.id, parsedInput.password);
|
||||
if (!isCorrectPassword) {
|
||||
throw new AuthorizationError("Incorrect credentials");
|
||||
}
|
||||
|
||||
const doesUserExist = await checkUserExistsByEmail(inputEmail);
|
||||
|
||||
if (doesUserExist) {
|
||||
throw new InvalidInputError("This email is already in use");
|
||||
}
|
||||
|
||||
if (EMAIL_VERIFICATION_DISABLED) {
|
||||
payload.email = inputEmail;
|
||||
} else {
|
||||
await sendVerificationNewEmail(ctx.user.id, inputEmail);
|
||||
}
|
||||
}
|
||||
|
||||
return await updateUser(ctx.user.id, payload);
|
||||
});
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
|
||||
@@ -50,11 +50,10 @@ describe("EditProfileDetailsForm", () => {
|
||||
test("renders with initial user data and updates successfully", async () => {
|
||||
vi.mocked(updateUserAction).mockResolvedValue({ ...mockUser, name: "New Name" } as any);
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={true} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
expect(nameInput).toHaveValue(mockUser.name);
|
||||
expect(screen.getByDisplayValue(mockUser.email)).toBeDisabled();
|
||||
// Check initial language (English)
|
||||
expect(screen.getByText("English (US)")).toBeInTheDocument();
|
||||
|
||||
@@ -72,7 +71,11 @@ describe("EditProfileDetailsForm", () => {
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserAction).toHaveBeenCalledWith({ name: "New Name", locale: "de-DE" });
|
||||
expect(updateUserAction).toHaveBeenCalledWith({
|
||||
name: "New Name",
|
||||
locale: "de-DE",
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
@@ -88,7 +91,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
const errorMessage = "Update failed";
|
||||
vi.mocked(updateUserAction).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("common.full_name");
|
||||
await userEvent.clear(nameInput);
|
||||
@@ -106,7 +109,7 @@ describe("EditProfileDetailsForm", () => {
|
||||
});
|
||||
|
||||
test("update button is disabled initially and enables on change", async () => {
|
||||
render(<EditProfileDetailsForm user={mockUser} />);
|
||||
render(<EditProfileDetailsForm user={mockUser} emailVerificationDisabled={false} />);
|
||||
const updateButton = screen.getByText("common.update");
|
||||
expect(updateButton).toBeDisabled();
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||
import { appLanguages } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -8,129 +10,214 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TUser, ZUser } from "@formbricks/types/user";
|
||||
import { TUser, TUserUpdateInput, ZUser } from "@formbricks/types/user";
|
||||
import { updateUserAction } from "../actions";
|
||||
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true });
|
||||
// Schema & types
|
||||
const ZEditProfileNameFormSchema = ZUser.pick({ name: true, locale: true, email: true });
|
||||
type TEditProfileNameForm = z.infer<typeof ZEditProfileNameFormSchema>;
|
||||
|
||||
export const EditProfileDetailsForm = ({ user }: { user: TUser }) => {
|
||||
export const EditProfileDetailsForm = ({
|
||||
user,
|
||||
emailVerificationDisabled,
|
||||
}: {
|
||||
user: TUser;
|
||||
emailVerificationDisabled: boolean;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<TEditProfileNameForm>({
|
||||
defaultValues: { name: user.name, locale: user.locale || "en" },
|
||||
defaultValues: {
|
||||
name: user.name,
|
||||
locale: user.locale,
|
||||
email: user.email,
|
||||
},
|
||||
mode: "onChange",
|
||||
resolver: zodResolver(ZEditProfileNameFormSchema),
|
||||
});
|
||||
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
const { t } = useTranslate();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const handleConfirmPassword = async (password: string) => {
|
||||
const values = form.getValues();
|
||||
const dirtyFields = form.formState.dirtyFields;
|
||||
|
||||
const emailChanged = "email" in dirtyFields;
|
||||
const nameChanged = "name" in dirtyFields;
|
||||
const localeChanged = "locale" in dirtyFields;
|
||||
|
||||
const name = values.name.trim();
|
||||
const email = values.email.trim().toLowerCase();
|
||||
const locale = values.locale;
|
||||
|
||||
const data: TUserUpdateInput = {};
|
||||
|
||||
if (emailChanged) {
|
||||
data.email = email;
|
||||
data.password = password;
|
||||
}
|
||||
if (nameChanged) {
|
||||
data.name = name;
|
||||
}
|
||||
if (localeChanged) {
|
||||
data.locale = locale;
|
||||
}
|
||||
|
||||
const updatedUserResult = await updateUserAction(data);
|
||||
|
||||
if (updatedUserResult?.data) {
|
||||
if (!emailVerificationDisabled) {
|
||||
toast.success(t("auth.verification-requested.verification_email_successfully_sent", { email }));
|
||||
} else {
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
await signOut({ redirect: false });
|
||||
router.push(`/email-change-without-verification-success`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedUserResult);
|
||||
toast.error(errorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
setShowModal(false);
|
||||
};
|
||||
|
||||
const onSubmit: SubmitHandler<TEditProfileNameForm> = async (data) => {
|
||||
try {
|
||||
const name = data.name.trim();
|
||||
const locale = data.locale;
|
||||
await updateUserAction({ name, locale });
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset({ name, locale });
|
||||
} catch (error) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
if (data.email !== user.email && data.email.toLowerCase() === user.email.toLowerCase()) {
|
||||
toast.error(t("auth.email-change.email_already_exists"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.email !== user.email) {
|
||||
setShowModal(true);
|
||||
} else {
|
||||
try {
|
||||
await updateUserAction({
|
||||
...data,
|
||||
name: data.name.trim(),
|
||||
});
|
||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||
window.location.reload();
|
||||
form.reset(data);
|
||||
} catch (error: any) {
|
||||
toast.error(`${t("common.error")}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm items-center" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
placeholder={t("common.full_name")}
|
||||
required
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<FormProvider {...form}>
|
||||
<form className="w-full max-w-sm" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.full_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
required
|
||||
placeholder={t("common.full_name")}
|
||||
isInvalid={!!form.formState.errors.name}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* disabled email field */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label htmlFor="email">{t("common.email")}</Label>
|
||||
<Input type="email" id="email" defaultValue={user.email} disabled />
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="email"
|
||||
required
|
||||
isInvalid={!!form.formState.errors.email}
|
||||
disabled={user.identityProvider !== "email"}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left"
|
||||
variant="ghost">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((language) => language.code === field.value)?.label[field.value] ||
|
||||
"NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-40 bg-slate-50 text-slate-700"
|
||||
align="start"
|
||||
side="bottom">
|
||||
{appLanguages.map((language) => (
|
||||
<DropdownMenuItem
|
||||
key={language.code}
|
||||
onClick={() => field.onChange(language.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{language.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{appLanguages.find((l) => l.code === field.value)?.label[field.value] ?? "NA"}
|
||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40 bg-slate-50 text-slate-700" align="start">
|
||||
{appLanguages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.code}
|
||||
onClick={() => field.onChange(lang.code)}
|
||||
className="min-h-8 cursor-pointer">
|
||||
{lang.label[field.value]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4"
|
||||
size="sm"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty}>
|
||||
{t("common.update")}
|
||||
</Button>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<PasswordConfirmationModal
|
||||
open={showModal}
|
||||
setOpen={setShowModal}
|
||||
oldEmail={user.email}
|
||||
newEmail={form.getValues("email") || user.email}
|
||||
onConfirm={handleConfirmPassword}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { PasswordConfirmationModal } from "./password-confirmation-modal";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, title }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="modal-title">{title}</div>
|
||||
{children}
|
||||
<button data-testid="modal-close" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the PasswordInput component
|
||||
vi.mock("@/modules/ui/components/password-input", () => ({
|
||||
PasswordInput: ({ onChange, value, placeholder }: any) => (
|
||||
<input
|
||||
type="password"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
data-testid="password-input"
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PasswordConfirmationModal", () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
setOpen: vi.fn(),
|
||||
oldEmail: "old@example.com",
|
||||
newEmail: "new@example.com",
|
||||
onConfirm: vi.fn(),
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders nothing when open is false", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders modal content when open is true", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays old and new email addresses", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
expect(screen.getByText("old@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("new@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows password input field", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
expect(passwordInput).toHaveAttribute("placeholder", "*******");
|
||||
});
|
||||
|
||||
test("disables confirm button when form is not dirty", () => {
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("disables confirm button when old and new emails are the same", () => {
|
||||
render(
|
||||
<PasswordConfirmationModal {...defaultProps} oldEmail="same@example.com" newEmail="same@example.com" />
|
||||
);
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("enables confirm button when password is entered and emails are different", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
expect(confirmButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("shows error message when password is too short", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "short");
|
||||
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PasswordConfirmationModal {...defaultProps} />);
|
||||
|
||||
const passwordInput = screen.getByTestId("password-input");
|
||||
await user.type(passwordInput, "password123");
|
||||
|
||||
const cancelButton = screen.getByText("common.cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(defaultProps.setOpen).toHaveBeenCalledWith(false);
|
||||
await waitFor(() => {
|
||||
expect(passwordInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ZUserPassword } from "@formbricks/types/user";
|
||||
|
||||
interface PasswordConfirmationModalProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
oldEmail: string;
|
||||
newEmail: string;
|
||||
onConfirm: (password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const PasswordConfirmationSchema = z.object({
|
||||
password: ZUserPassword,
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof PasswordConfirmationSchema>;
|
||||
|
||||
export const PasswordConfirmationModal = ({
|
||||
open,
|
||||
setOpen,
|
||||
oldEmail,
|
||||
newEmail,
|
||||
onConfirm,
|
||||
}: PasswordConfirmationModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(PasswordConfirmationSchema),
|
||||
});
|
||||
const { isSubmitting, isDirty } = form.formState;
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (data) => {
|
||||
try {
|
||||
await onConfirm(data.password);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
form.setError("password", {
|
||||
message: error instanceof Error ? error.message : "Authentication failed",
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("auth.email-change.confirm_password_description")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
|
||||
<p>
|
||||
<strong>{t("auth.email-change.old_email")}:</strong>
|
||||
<br /> {oldEmail.toLowerCase()}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{t("auth.email-change.new_email")}:</strong>
|
||||
<br /> {newEmail.toLowerCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="*******"
|
||||
aria-placeholder="password"
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
{error?.message && <FormError className="text-left">{error.message}</FormError>}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-x-2 text-right">
|
||||
<Button type="button" variant="secondary" onClick={handleCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="default"
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
|
||||
{t("common.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import { verifyPassword as mockVerifyPasswordImported } from "@/modules/auth/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { checkUserExistsByEmail, verifyUserPassword } from "./user";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/user/cache", () => ({
|
||||
userCache: {
|
||||
tag: {
|
||||
byId: vi.fn((id) => `user-${id}-tag`),
|
||||
byEmail: vi.fn((email) => `user-email-${email}-tag`),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/utils", () => ({
|
||||
verifyPassword: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// reactCache (from "react") and unstable_cache (from "next/cache") are mocked in vitestSetup.ts
|
||||
// to be pass-through, so the inner logic of cached functions is tested.
|
||||
|
||||
const mockPrismaUserFindUnique = vi.mocked(prisma.user.findUnique);
|
||||
const mockVerifyPasswordUtil = vi.mocked(mockVerifyPasswordImported);
|
||||
|
||||
describe("User Library Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("verifyUserPassword", () => {
|
||||
const userId = "test-user-id";
|
||||
const password = "test-password";
|
||||
|
||||
test("should return true for correct password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(true);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should return false for incorrect password", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
mockVerifyPasswordUtil.mockResolvedValue(false);
|
||||
|
||||
const result = await verifyUserPassword(userId, password);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if user not found", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if identityProvider is not email", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: "hashed-password",
|
||||
identityProvider: "google", // Not 'email'
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError if password is not set for email provider", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
password: null, // Password not set
|
||||
identityProvider: "email",
|
||||
} as any);
|
||||
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
|
||||
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
select: { password: true, identityProvider: true },
|
||||
});
|
||||
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkUserExistsByEmail", () => {
|
||||
const email = "test@example.com";
|
||||
|
||||
test("should return true if user exists", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue({
|
||||
id: "some-user-id",
|
||||
} as any);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(true);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false if user does not exist", async () => {
|
||||
mockPrismaUserFindUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await checkUserExistsByEmail(email);
|
||||
expect(result).toBe(false);
|
||||
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { userCache } from "@/lib/user/cache";
|
||||
import { verifyPassword } from "@/modules/auth/lib/utils";
|
||||
import { User } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getUserById = reactCache(
|
||||
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
identityProvider: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", userId);
|
||||
}
|
||||
return user;
|
||||
},
|
||||
[`getUserById-${userId}`],
|
||||
{
|
||||
tags: [userCache.tag.byId(userId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (user.identityProvider !== "email" || !user.password) {
|
||||
throw new InvalidInputError("Password is not set for this user");
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(password, user.password);
|
||||
|
||||
if (!isCorrectPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const checkUserExistsByEmail = reactCache(
|
||||
async (email: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!user;
|
||||
},
|
||||
[`checkUserExistsByEmail-${email}`],
|
||||
{
|
||||
tags: [userCache.tag.byEmail(email)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -13,6 +13,7 @@ import Page from "./page";
|
||||
// Mock services and utils
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
EMAIL_VERIFICATION_DISABLED: true,
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -42,7 +42,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
<SettingsCard
|
||||
title={t("environments.settings.profile.personal_information")}
|
||||
description={t("environments.settings.profile.update_personal_info")}>
|
||||
<EditProfileDetailsForm user={user} />
|
||||
<EditProfileDetailsForm emailVerificationDisabled={EMAIL_VERIFICATION_DISABLED} user={user} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title={t("common.avatar")}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import EnterpriseSettingsPage from "./page";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -179,15 +178,23 @@ describe("EnterpriseSettingsPage", () => {
|
||||
});
|
||||
|
||||
test("renders correctly for an owner when not on Formbricks Cloud", async () => {
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { default: EnterpriseSettingsPage } = await import("./page");
|
||||
const Page = await EnterpriseSettingsPage({ params: { environmentId: mockEnvironmentId } });
|
||||
render(Page);
|
||||
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("environments.settings.enterprise.sso")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.settings.billing.remove_branding")).toBeInTheDocument();
|
||||
|
||||
expect(redirect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
describe("TeamsPage re-export", () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SettingsCard = ({
|
||||
id={title}>
|
||||
<div className="border-b border-slate-200 px-4 pb-4">
|
||||
<div className="flex">
|
||||
<h3 className="text-lg leading-6 font-medium text-slate-900 capitalize">{title}</h3>
|
||||
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
|
||||
<div className="ml-2">
|
||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||
{soon && (
|
||||
|
||||
@@ -45,6 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: 587,
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||
|
||||
@@ -1,487 +1,494 @@
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import type { DragEndEvent } from "@dnd-kit/core";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
import { ResponseTable } from "./ResponseTable";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
// Hoist variables used in mock factories
|
||||
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
|
||||
const dndMock = vi.fn(({ children, onDragEnd }) => {
|
||||
// Store the onDragEnd prop to allow triggering it in tests
|
||||
(dndMock as any).lastOnDragEnd = onDragEnd;
|
||||
return <div data-testid="dnd-context">{children}</div>;
|
||||
});
|
||||
const sortableMock = vi.fn(({ children }) => <>{children}</>);
|
||||
const moveMock = vi.fn((array, from, to) => {
|
||||
const newArray = [...array];
|
||||
const [item] = newArray.splice(from, 1);
|
||||
newArray.splice(to, 0, item);
|
||||
return newArray;
|
||||
});
|
||||
return {
|
||||
DndContextMock: dndMock,
|
||||
SortableContextMock: sortableMock,
|
||||
arrayMoveMock: moveMock,
|
||||
};
|
||||
});
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
|
||||
return {
|
||||
...actual,
|
||||
DndContext: DndContextMock,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(),
|
||||
closestCenter: vi.fn(),
|
||||
};
|
||||
});
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} data-testid="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock DndContext/SortableContext
|
||||
vi.mock("@dnd-kit/core", () => ({
|
||||
DndContext: ({ children }: any) => <div>{children}</div>,
|
||||
useSensor: vi.fn(),
|
||||
useSensors: vi.fn(() => "sensors"),
|
||||
closestCenter: vi.fn(),
|
||||
MouseSensor: vi.fn(),
|
||||
TouchSensor: vi.fn(),
|
||||
KeyboardSensor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/modifiers", () => ({
|
||||
restrictToHorizontalAxis: vi.fn(),
|
||||
restrictToHorizontalAxis: "restrictToHorizontalAxis",
|
||||
}));
|
||||
|
||||
vi.mock("@dnd-kit/sortable", () => ({
|
||||
SortableContext: SortableContextMock,
|
||||
arrayMove: arrayMoveMock,
|
||||
horizontalListSortingStrategy: vi.fn(),
|
||||
SortableContext: ({ children }: any) => <>{children}</>,
|
||||
horizontalListSortingStrategy: "horizontalListSortingStrategy",
|
||||
arrayMove: vi.fn((arr, oldIndex, newIndex) => {
|
||||
const result = [...arr];
|
||||
const [removed] = result.splice(oldIndex, 1);
|
||||
result.splice(newIndex, 0, removed);
|
||||
return result;
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AutoAnimate
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: () => [vi.fn()],
|
||||
}));
|
||||
|
||||
// Mock UI components
|
||||
vi.mock("@/modules/ui/components/data-table", () => ({
|
||||
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
|
||||
DataTableSettingsModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="settings-modal">
|
||||
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null,
|
||||
DataTableToolbar: ({
|
||||
table,
|
||||
deleteRowsAction,
|
||||
downloadRowsAction,
|
||||
setIsTableSettingsModalOpen,
|
||||
setIsExpanded,
|
||||
isExpanded,
|
||||
}: any) => (
|
||||
<div data-testid="table-toolbar">
|
||||
<button
|
||||
data-testid="toggle-expand"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
aria-pressed={isExpanded}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="delete-rows"
|
||||
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-csv"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
|
||||
Download CSV
|
||||
</button>
|
||||
<button
|
||||
data-testid="download-xlsx"
|
||||
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
|
||||
Download XLSX
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
|
||||
() => ({
|
||||
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
|
||||
ResponseCardModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="response-card-modal">
|
||||
Selected Response ID: {selectedResponseId}
|
||||
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
|
||||
<div data-testid="response-modal">
|
||||
Response Modal <button onClick={() => setOpen(false)}>Close</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
|
||||
() => ({
|
||||
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
|
||||
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
|
||||
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
|
||||
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
|
||||
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
|
||||
Cell Content
|
||||
</td>
|
||||
)),
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
const mockGeneratedColumns = [
|
||||
{
|
||||
id: "select",
|
||||
header: () => "Select",
|
||||
cell: vi.fn(() => "SelectCell"),
|
||||
enableSorting: false,
|
||||
meta: { type: "select", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: () => "Created At",
|
||||
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
|
||||
enableSorting: true,
|
||||
meta: { type: "createdAt", questionType: null, hidden: false },
|
||||
},
|
||||
{
|
||||
id: "q1",
|
||||
header: () => "Question 1",
|
||||
cell: vi.fn(({ row }) => row.original.responseData.q1),
|
||||
enableSorting: true,
|
||||
meta: { type: "question", questionType: "openText", hidden: false },
|
||||
},
|
||||
];
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
|
||||
() => ({
|
||||
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
|
||||
generateResponseTableColumns: vi.fn(() => [
|
||||
{ id: "select", accessorKey: "select", header: "Select" },
|
||||
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
|
||||
{ id: "person", accessorKey: "person", header: "Person" },
|
||||
{ id: "status", accessorKey: "status", header: "Status" },
|
||||
]),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/modules/ui/components/table", () => ({
|
||||
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
|
||||
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
|
||||
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
|
||||
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
|
||||
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/skeleton", () => ({
|
||||
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock the actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
|
||||
getResponsesDownloadUrlAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
|
||||
deleteResponseAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
|
||||
return {
|
||||
...actual,
|
||||
DataTableToolbar: vi.fn((props) => (
|
||||
<div data-testid="data-table-toolbar">
|
||||
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
|
||||
Toggle Expand
|
||||
</button>
|
||||
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
|
||||
Open Settings
|
||||
</button>
|
||||
<button
|
||||
data-testid="toolbar-delete-selected"
|
||||
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
|
||||
Delete Selected
|
||||
</button>
|
||||
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
|
||||
Delete Single Action
|
||||
</button>
|
||||
</div>
|
||||
)),
|
||||
DataTableHeader: vi.fn(({ header }) => (
|
||||
<th
|
||||
data-testid={`header-${header.id}`}
|
||||
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
|
||||
{typeof header.column.columnDef.header === "function"
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
<button
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
data-testid={`resize-${header.id}`}>
|
||||
Resize
|
||||
</button>
|
||||
</th>
|
||||
)),
|
||||
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
|
||||
open ? (
|
||||
<div data-testid="data-table-settings-modal">
|
||||
<button onClick={() => setOpen(false)}>Close Settings</button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@formkit/auto-animate/react", () => ({
|
||||
useAutoAnimate: vi.fn(() => [vi.fn()]),
|
||||
// Mock helper functions
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: vi.fn((key) => key), // Simple pass-through mock
|
||||
}),
|
||||
}));
|
||||
|
||||
const localStorageMock = (() => {
|
||||
// Mock localStorage
|
||||
const mockLocalStorage = (() => {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString();
|
||||
getItem: vi.fn((key) => store[key] || null),
|
||||
setItem: vi.fn((key, value) => {
|
||||
store[key] = String(value);
|
||||
}),
|
||||
clear: () => {
|
||||
clear: vi.fn(() => {
|
||||
store = {};
|
||||
},
|
||||
removeItem: vi.fn((key: string) => {
|
||||
}),
|
||||
removeItem: vi.fn((key) => {
|
||||
delete store[key];
|
||||
}),
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock });
|
||||
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
|
||||
|
||||
const mockSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question 1" },
|
||||
required: true,
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
|
||||
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
environmentId: "env1",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "" },
|
||||
html: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
closeOnDate: null,
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
singleUse: { enabled: false, isEncrypted: true },
|
||||
triggers: [],
|
||||
languages: [],
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
} as unknown as TSurvey;
|
||||
// Mock Tolgee
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockResponses: TResponse[] = [
|
||||
{
|
||||
id: "res1",
|
||||
surveyId: "survey1",
|
||||
finished: true,
|
||||
data: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
// Define mock data for tests
|
||||
const mockProps = {
|
||||
data: [
|
||||
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
|
||||
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
|
||||
] as any[],
|
||||
survey: {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
id: "res2",
|
||||
surveyId: "survey1",
|
||||
finished: false,
|
||||
data: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
updatedAt: new Date(),
|
||||
meta: {},
|
||||
singleUseId: null,
|
||||
ttc: {},
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
language: "en",
|
||||
contact: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockResponseTableData: TResponseTableData[] = [
|
||||
{
|
||||
responseId: "res1",
|
||||
responseData: { q1: "Response 1 Text" },
|
||||
createdAt: new Date("2023-01-01T10:00:00.000Z"),
|
||||
status: "Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
{
|
||||
responseId: "res2",
|
||||
responseData: { q1: "Response 2 Text" },
|
||||
createdAt: new Date("2023-01-02T10:00:00.000Z"),
|
||||
status: "Not Completed",
|
||||
tags: [],
|
||||
notes: [],
|
||||
variables: {},
|
||||
verifiedEmail: "",
|
||||
language: "en",
|
||||
person: null,
|
||||
contactAttributes: null,
|
||||
},
|
||||
];
|
||||
|
||||
const mockEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "user@test.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockEnvironmentTags: TTag[] = [
|
||||
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
const mockLocale: TUserLocale = "en-US";
|
||||
|
||||
const defaultProps = {
|
||||
data: mockResponseTableData,
|
||||
survey: mockSurvey,
|
||||
responses: mockResponses,
|
||||
environment: mockEnvironment,
|
||||
user: mockUser,
|
||||
environmentTags: mockEnvironmentTags,
|
||||
name: "name",
|
||||
type: "link",
|
||||
environmentId: "env-1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
} as TSurvey,
|
||||
responses: [
|
||||
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
|
||||
] as TResponse[],
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentTags: [] as TTag[],
|
||||
isReadOnly: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
hasMore: true,
|
||||
hasMore: false,
|
||||
deleteResponses: vi.fn(),
|
||||
updateResponse: vi.fn(),
|
||||
isFetchingFirstPage: false,
|
||||
locale: mockLocale,
|
||||
locale: "en" as TUserLocale,
|
||||
};
|
||||
|
||||
// Setup a container for React Testing Library before each test
|
||||
beforeEach(() => {
|
||||
const container = document.createElement("div");
|
||||
container.id = "test-container";
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Reset all toast mocks before each test
|
||||
vi.mocked(toast.error).mockClear();
|
||||
vi.mocked(toast.success).mockClear();
|
||||
|
||||
// Create a mock anchor element for download tests
|
||||
const mockAnchor = {
|
||||
href: "",
|
||||
click: vi.fn(),
|
||||
style: {},
|
||||
};
|
||||
|
||||
// Update how we mock the document methods to avoid infinite recursion
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||
if (tagName === "a") return mockAnchor as any;
|
||||
return originalCreateElement(tagName);
|
||||
});
|
||||
|
||||
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
|
||||
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
|
||||
});
|
||||
|
||||
// Cleanup after each test
|
||||
afterEach(() => {
|
||||
const container = document.getElementById("test-container");
|
||||
if (container) {
|
||||
document.body.removeChild(container);
|
||||
}
|
||||
cleanup();
|
||||
vi.restoreAllMocks(); // Restore mocks after each test
|
||||
});
|
||||
|
||||
describe("ResponseTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorageMock.clear();
|
||||
vi.clearAllMocks();
|
||||
cleanup(); // Keep cleanup within describe as per instructions
|
||||
});
|
||||
|
||||
test("renders skeleton when isFetchingFirstPage is true", () => {
|
||||
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
|
||||
// Check for skeleton elements (implementation detail, might need adjustment)
|
||||
// For now, check that data is not directly rendered
|
||||
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
|
||||
// Check if table headers are still there
|
||||
expect(screen.getByText("Created At")).toBeInTheDocument();
|
||||
test("renders the table with data", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
expect(screen.getByRole("table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("loads settings from localStorage on mount", () => {
|
||||
const savedOrder = ["q1", "createdAt", "select"];
|
||||
const savedVisibility = { createdAt: false };
|
||||
const savedExpanded = true;
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
|
||||
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
|
||||
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Check if generateResponseTableColumns was called with the loaded expanded state
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
savedExpanded,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
test("saves settings to localStorage when they change", async () => {
|
||||
const { rerender } = render(<ResponseTable {...defaultProps} />);
|
||||
|
||||
// Simulate column order change via DND
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"])
|
||||
);
|
||||
|
||||
// Simulate visibility change (e.g. via settings modal - direct state change for test)
|
||||
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
|
||||
// For this test, we'll assume a mechanism changes columnVisibility state
|
||||
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
|
||||
|
||||
// Simulate row expansion change
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
});
|
||||
|
||||
test("handles column drag and drop", () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
const dragEvent: DragEndEvent = {
|
||||
active: { id: "createdAt" },
|
||||
over: { id: "q1" },
|
||||
delta: { x: 0, y: 0 },
|
||||
activators: { x: 0, y: 0 },
|
||||
collisions: null,
|
||||
overNode: null,
|
||||
activeNode: null,
|
||||
} as any;
|
||||
act(() => {
|
||||
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
|
||||
});
|
||||
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
`${mockSurvey.id}-columnOrder`,
|
||||
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
|
||||
);
|
||||
});
|
||||
|
||||
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
|
||||
const deleteResponsesMock = vi.fn();
|
||||
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
|
||||
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
|
||||
|
||||
// Toggle expand
|
||||
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
|
||||
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
|
||||
mockSurvey,
|
||||
true,
|
||||
false,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
|
||||
|
||||
// Open settings
|
||||
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
|
||||
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("Close Settings"));
|
||||
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
|
||||
|
||||
// Delete selected (mock table selection)
|
||||
// This requires mocking table.getSelectedRowModel().rows
|
||||
// For simplicity, we assume the toolbar button calls deleteRows correctly
|
||||
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
|
||||
// To test properly, we'd need to mock table.getSelectedRowModel
|
||||
// For now, let's assume the mock toolbar calls it.
|
||||
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
|
||||
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
|
||||
|
||||
// Delete single action
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when 'Load More' is clicked", async () => {
|
||||
const fetchNextPageMock = vi.fn();
|
||||
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(fetchNextPageMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not show 'Load More' if hasMore is false", () => {
|
||||
render(<ResponseTable {...defaultProps} hasMore={false} />);
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'No results' when data is empty", () => {
|
||||
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
|
||||
test("renders no results message when data is empty", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
|
||||
expect(screen.getByText("common.no_results")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("deleteResponse function calls deleteResponseAction", async () => {
|
||||
render(<ResponseTable {...defaultProps} />);
|
||||
// This function is called by DataTableToolbar's deleteAction prop
|
||||
// We can trigger it via the mocked DataTableToolbar
|
||||
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
|
||||
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
|
||||
test("renders load more button when hasMore is true", () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
expect(screen.getByText("common.load_more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls fetchNextPage when load more button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
|
||||
const loadMoreButton = screen.getByText("common.load_more");
|
||||
await userEvent.click(loadMoreButton);
|
||||
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("opens settings modal when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const openSettingsButton = screen.getByTestId("open-settings");
|
||||
await userEvent.click(openSettingsButton);
|
||||
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("toggles expanded state when toolbar button is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
|
||||
// Initially might be null, first click should set it to true
|
||||
await userEvent.click(toggleExpandButton);
|
||||
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.csv",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "csv",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.csv");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: "https://download.url/file.xlsx",
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadXlsxButton = screen.getByTestId("download-xlsx");
|
||||
await userEvent.click(downloadXlsxButton);
|
||||
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
|
||||
surveyId: "survey1",
|
||||
format: "xlsx",
|
||||
filterCriteria: { responseIds: [] },
|
||||
});
|
||||
|
||||
// Check if link was created and clicked
|
||||
expect(document.createElement).toHaveBeenCalledWith("a");
|
||||
const mockLink = document.createElement("a");
|
||||
expect(mockLink.href).toBe("https://download.url/file.xlsx");
|
||||
expect(document.body.appendChild).toHaveBeenCalled();
|
||||
expect(mockLink.click).toHaveBeenCalled();
|
||||
expect(document.body.removeChild).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test response modal
|
||||
test("opens and closes response modal when a cell is clicked", async () => {
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const cell = screen.getByTestId("cell-resp1_select-resp1");
|
||||
await userEvent.click(cell);
|
||||
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
|
||||
// Close the modal
|
||||
const closeButton = screen.getByText("Close");
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
// Modal should be closed now
|
||||
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error toast when download action returns error", async () => {
|
||||
const errorMsg = "Download failed";
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: errorMsg,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
|
||||
|
||||
// Reset document.createElement spy to fix the last test
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows default error toast when download action returns no data", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
});
|
||||
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("shows error toast when download action throws exception", async () => {
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
|
||||
});
|
||||
});
|
||||
|
||||
test("does not create download link when download action fails", async () => {
|
||||
// Clear any previous calls to document.createElement
|
||||
vi.mocked(document.createElement).mockClear();
|
||||
|
||||
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
|
||||
data: undefined,
|
||||
serverError: "Download failed",
|
||||
});
|
||||
|
||||
// Create a fresh spy for createElement for this test only
|
||||
const createElementSpy = vi.spyOn(document, "createElement");
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
const downloadCsvButton = screen.getByTestId("download-csv");
|
||||
await userEvent.click(downloadCsvButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
|
||||
// Check specifically for "a" element creation, not any element
|
||||
expect(createElementSpy).not.toHaveBeenCalledWith("a");
|
||||
});
|
||||
});
|
||||
|
||||
test("loads saved settings from localStorage on mount", () => {
|
||||
const columnOrder = ["status", "person", "createdAt", "select"];
|
||||
const columnVisibility = { status: false };
|
||||
const isExpanded = true;
|
||||
|
||||
mockLocalStorage.getItem.mockImplementation((key) => {
|
||||
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
|
||||
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
|
||||
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
|
||||
return null;
|
||||
});
|
||||
|
||||
const container = document.getElementById("test-container");
|
||||
render(<ResponseTable {...mockProps} />, { container: container! });
|
||||
|
||||
// Verify localStorage calls
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
|
||||
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
|
||||
|
||||
// The mock for generateResponseTableColumns returns this order:
|
||||
// ["select", "createdAt", "person", "status"]
|
||||
// Only visible columns should be rendered, in this order
|
||||
const expectedHeaders = ["select", "createdAt", "person"];
|
||||
const headers = screen.getAllByTestId(/^header-/);
|
||||
expect(headers).toHaveLength(expectedHeaders.length);
|
||||
expectedHeaders.forEach((columnId, index) => {
|
||||
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
|
||||
});
|
||||
|
||||
// Verify column visibility is applied
|
||||
const statusHeader = screen.queryByTestId("header-status");
|
||||
expect(statusHeader).not.toBeInTheDocument();
|
||||
|
||||
// Verify row expansion is applied
|
||||
const toggleExpandButton = screen.getByTestId("toggle-expand");
|
||||
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
|
||||
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
|
||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -25,15 +26,16 @@ import {
|
||||
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
interface ResponseTableProps {
|
||||
data: TResponseTableData[];
|
||||
@@ -180,6 +182,32 @@ export const ResponseTable = ({
|
||||
await deleteResponseAction({ responseId });
|
||||
};
|
||||
|
||||
// Handle downloading selected responses
|
||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
||||
try {
|
||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||
surveyId: survey.id,
|
||||
format: format,
|
||||
filterCriteria: { responseIds },
|
||||
});
|
||||
|
||||
if (downloadResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadResponse.data;
|
||||
link.download = "";
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} else {
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
} catch (error) {
|
||||
Sentry.captureException(error);
|
||||
toast.error(t("environments.surveys.responses.error_downloading_responses"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DndContext
|
||||
@@ -193,9 +221,10 @@ export const ResponseTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deleteResponses}
|
||||
deleteRowsAction={deleteResponses}
|
||||
type="response"
|
||||
deleteAction={deleteResponse}
|
||||
downloadRowsAction={downloadSelectedRows}
|
||||
/>
|
||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||
<div className="w-full overflow-x-auto">
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ResponseTableCell = ({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 hover:border-slate-300 focus:outline-none group-hover:flex"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
@@ -41,7 +41,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -80,7 +80,7 @@ export const DateQuestionSummary = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{renderResponseValue(response.value)}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -80,7 +80,7 @@ export const FileUploadSummary = ({
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute top-0 right-0 m-2">
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
|
||||
<DownloadIcon className="h-6 text-slate-500" />
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">{questionSummary.id}</h3>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 pl-6 font-semibold whitespace-pre-wrap">
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{response.value}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">
|
||||
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
|
||||
@@ -83,7 +83,7 @@ export const MultipleChoiceSummary = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
|
||||
@@ -62,7 +62,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
@@ -72,7 +72,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -94,7 +94,7 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -26,7 +26,7 @@ export const QuestionSummaryHeader = ({
|
||||
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<div className={"align-center flex justify-between gap-4"}>
|
||||
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
|
||||
{formatTextWithSlashes(
|
||||
|
||||
@@ -50,7 +50,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
|
||||
@@ -61,10 +61,10 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">
|
||||
{quesDropOff.ttc > 0 ? (quesDropOff.ttc / 1000).toFixed(2) + "s" : "N/A"}
|
||||
</div>
|
||||
<div className="text-center font-semibold whitespace-pre-wrap">{quesDropOff.impressions}</div>
|
||||
<div className="whitespace-pre-wrap text-center font-semibold">{quesDropOff.impressions}</div>
|
||||
<div className="pl-6 text-center md:px-6">
|
||||
<span className="mr-1.5 font-semibold">{quesDropOff.dropOffCount}</span>
|
||||
<span>({Math.round(quesDropOff.dropOffPercentage)}%)</span>
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children, onClick }) => (
|
||||
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
|
||||
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
// Create a spy for refreshSingleUseId so we can override it in tests
|
||||
|
||||
@@ -28,7 +28,7 @@ export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl]);
|
||||
}, [surveyUrl, t]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
|
||||
@@ -96,7 +96,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
|
||||
throw new ResourceNotFoundError("Organization not found", organizationId);
|
||||
}
|
||||
|
||||
const isSurveyFollowUpsEnabled = getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
if (!isSurveyFollowUpsEnabled) {
|
||||
throw new OperationNotAllowedError("Survey follow ups are not enabled for this organization");
|
||||
}
|
||||
|
||||
@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
if (responsesDownloadUrlResponse?.data) {
|
||||
const link = document.createElement("a");
|
||||
link.href = responsesDownloadUrlResponse.data;
|
||||
link.download = "";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -390,7 +391,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
value && handleDatePickerClose();
|
||||
}}>
|
||||
<DropdownMenuTrigger asChild className="focus:bg-muted cursor-pointer outline-none">
|
||||
<div className="h-auto min-w-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="min-w-auto h-auto rounded-md border border-slate-200 bg-white p-3 hover:border-slate-300 sm:flex sm:px-6 sm:py-3">
|
||||
<div className="hidden w-full items-center justify-between sm:flex">
|
||||
<span className="text-sm text-slate-700">{t("common.download")}</span>
|
||||
<ArrowDownToLineIcon className="ml-2 h-4 w-4" />
|
||||
|
||||
@@ -91,7 +91,7 @@ export const QuestionFilterComboBox = ({
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
className="w-30 flex items-center whitespace-nowrap bg-slate-100 px-2 text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
@@ -129,7 +129,7 @@ export const QuestionFilterComboBox = ({
|
||||
<DropdownMenuTrigger
|
||||
disabled={disabled}
|
||||
className={clsx(
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:ring-0 focus:outline-transparent",
|
||||
"h-9 max-w-fit rounded-md rounded-r-none border-r-[1px] border-slate-300 bg-white p-2 text-sm text-slate-600 focus:outline-transparent focus:ring-0",
|
||||
!disabled ? "cursor-pointer" : "opacity-50"
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import {
|
||||
OptionsType,
|
||||
QuestionOption,
|
||||
QuestionOptions,
|
||||
QuestionsComboBox,
|
||||
SelectedCommandItem,
|
||||
} from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
@@ -53,3 +60,67 @@ describe("QuestionsComboBox", () => {
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
|
||||
describe("SelectedCommandItem", () => {
|
||||
test("renders question icon and color for QUESTIONS with questionType", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem
|
||||
label="Q1"
|
||||
type={OptionsType.QUESTIONS}
|
||||
questionType={TSurveyQuestionTypeEnum.OpenText}
|
||||
/>
|
||||
);
|
||||
expect(container.querySelector(".bg-brand-dark")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Q1");
|
||||
});
|
||||
|
||||
test("renders attribute icon and color for ATTRIBUTES", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Attr" type={OptionsType.ATTRIBUTES} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Attr");
|
||||
});
|
||||
|
||||
test("renders hidden field icon and color for HIDDEN_FIELDS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Hidden" type={OptionsType.HIDDEN_FIELDS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Hidden");
|
||||
});
|
||||
|
||||
test("renders meta icon and color for META with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="device" type={OptionsType.META} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("device");
|
||||
});
|
||||
|
||||
test("renders other icon and color for OTHERS with label", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Language" type={OptionsType.OTHERS} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Language");
|
||||
});
|
||||
|
||||
test("renders tag icon and color for TAGS", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Tag1" type={OptionsType.TAGS} />);
|
||||
expect(container.querySelector(".bg-indigo-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Tag1");
|
||||
});
|
||||
|
||||
test("renders fallback color and no icon for unknown type", () => {
|
||||
const { container } = render(<SelectedCommandItem label="Unknown" type={"UNKNOWN"} />);
|
||||
expect(container.querySelector(".bg-amber-500")).toBeInTheDocument();
|
||||
expect(container.querySelector("svg")).not.toBeInTheDocument();
|
||||
expect(container.textContent).toContain("Unknown");
|
||||
});
|
||||
|
||||
test("renders fallback for non-string label", () => {
|
||||
const { container } = render(
|
||||
<SelectedCommandItem label={{ default: "NonString" }} type={OptionsType.QUESTIONS} />
|
||||
);
|
||||
expect(container.textContent).toContain("NonString");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ImageIcon,
|
||||
LanguagesIcon,
|
||||
ListIcon,
|
||||
@@ -63,59 +64,60 @@ interface QuestionComboBoxProps {
|
||||
onChangeValue: (option: QuestionOption) => void;
|
||||
}
|
||||
|
||||
const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
switch (type) {
|
||||
case OptionsType.QUESTIONS:
|
||||
switch (questionType) {
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <MessageSquareTextIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Rating:
|
||||
return <StarIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.CTA:
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.OpenText:
|
||||
return <HelpCircleIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceMulti:
|
||||
return <ListIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.MultipleChoiceSingle:
|
||||
return <Rows3Icon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.NPS:
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.PictureSelection:
|
||||
return <ImageIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Matrix:
|
||||
return <GridIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionTypeEnum.Ranking:
|
||||
return <ListOrderedIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.ATTRIBUTES:
|
||||
return <User width={18} height={18} className="text-white" />;
|
||||
const questionIcons = {
|
||||
// questions
|
||||
[TSurveyQuestionTypeEnum.OpenText]: MessageSquareTextIcon,
|
||||
[TSurveyQuestionTypeEnum.Rating]: StarIcon,
|
||||
[TSurveyQuestionTypeEnum.CTA]: MousePointerClickIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: ListIcon,
|
||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: Rows3Icon,
|
||||
[TSurveyQuestionTypeEnum.NPS]: NetPromoterScoreIcon,
|
||||
[TSurveyQuestionTypeEnum.Consent]: CheckIcon,
|
||||
[TSurveyQuestionTypeEnum.PictureSelection]: ImageIcon,
|
||||
[TSurveyQuestionTypeEnum.Matrix]: GridIcon,
|
||||
[TSurveyQuestionTypeEnum.Ranking]: ListOrderedIcon,
|
||||
[TSurveyQuestionTypeEnum.Address]: HomeIcon,
|
||||
[TSurveyQuestionTypeEnum.ContactInfo]: ContactIcon,
|
||||
|
||||
case OptionsType.HIDDEN_FIELDS:
|
||||
return <EyeOff width={18} height={18} className="text-white" />;
|
||||
case OptionsType.META:
|
||||
switch (label) {
|
||||
case "device":
|
||||
return <SmartphoneIcon width={18} height={18} className="text-white" />;
|
||||
case "os":
|
||||
return <AirplayIcon width={18} height={18} className="text-white" />;
|
||||
case "browser":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "source":
|
||||
return <GlobeIcon width={18} height={18} className="text-white" />;
|
||||
case "action":
|
||||
return <MousePointerClickIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.OTHERS:
|
||||
switch (label) {
|
||||
case "Language":
|
||||
return <LanguagesIcon width={18} height={18} className="text-white" />;
|
||||
}
|
||||
case OptionsType.TAGS:
|
||||
return <HashIcon width={18} height={18} className="text-white" />;
|
||||
// attributes
|
||||
[OptionsType.ATTRIBUTES]: User,
|
||||
|
||||
// hidden fields
|
||||
[OptionsType.HIDDEN_FIELDS]: EyeOff,
|
||||
|
||||
// meta
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
action: MousePointerClickIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
|
||||
// tags
|
||||
[OptionsType.TAGS]: HashIcon,
|
||||
};
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const IconComponent = questionIcons[type];
|
||||
return IconComponent ? <IconComponent width={18} height={18} className="text-white" /> : null;
|
||||
};
|
||||
|
||||
export const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOption>) => {
|
||||
const getIconType = () => {
|
||||
if (type) {
|
||||
if (type === OptionsType.QUESTIONS && questionType) {
|
||||
return getIcon(questionType);
|
||||
} else if (type === OptionsType.ATTRIBUTES) {
|
||||
return getIcon(OptionsType.ATTRIBUTES);
|
||||
} else if (type === OptionsType.HIDDEN_FIELDS) {
|
||||
return getIcon(OptionsType.HIDDEN_FIELDS);
|
||||
} else if ([OptionsType.META, OptionsType.OTHERS].includes(type) && label) {
|
||||
return getIcon(label);
|
||||
} else if (type === OptionsType.TAGS) {
|
||||
return getIcon(OptionsType.TAGS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,7 +166,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={t("common.search") + "..."}
|
||||
className="h-5 border-none border-transparent p-0 shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
|
||||
className="h-5 border-none border-transparent p-0 shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
|
||||
@@ -38,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
POSTHOG_API_KEY: "test-posthog-api-key",
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import EmailChangeWithoutVerificationSuccessPage from "./page";
|
||||
|
||||
vi.mock("@/modules/auth/email-change-without-verification-success/page", () => ({
|
||||
EmailChangeWithoutVerificationSuccessPage: ({ children }) => (
|
||||
<div data-testid="email-change-success-page">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders EmailChangeWithoutVerificationSuccessPage", () => {
|
||||
const { getByTestId } = render(<EmailChangeWithoutVerificationSuccessPage />);
|
||||
expect(getByTestId("email-change-success-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { EmailChangeWithoutVerificationSuccessPage } from "@/modules/auth/email-change-without-verification-success/page";
|
||||
|
||||
export default EmailChangeWithoutVerificationSuccessPage;
|
||||
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
3
apps/web/app/(auth)/verify-email-change/page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { VerifyEmailChangePage } from "@/modules/auth/verify-email-change/page";
|
||||
|
||||
export default VerifyEmailChangePage;
|
||||
@@ -1,235 +0,0 @@
|
||||
import {
|
||||
mockContactEmailFollowUp,
|
||||
mockDirectEmailFollowUp,
|
||||
mockEndingFollowUp,
|
||||
mockEndingId2,
|
||||
mockResponse,
|
||||
mockResponseEmailFollowUp,
|
||||
mockResponseWithContactQuestion,
|
||||
mockSurvey,
|
||||
mockSurveyWithContactQuestion,
|
||||
} from "@/app/api/(internal)/pipeline/lib/__mocks__/survey-follow-up.mock";
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { evaluateFollowUp, sendSurveyFollowUps } from "./survey-follow-up";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendFollowUpEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Follow Up", () => {
|
||||
const mockOrganization: Partial<TOrganization> = {
|
||||
id: "org1",
|
||||
name: "Test Org",
|
||||
whitelabel: {
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
},
|
||||
};
|
||||
|
||||
describe("evaluateFollowUp", () => {
|
||||
test("sends email when to is a direct email address", async () => {
|
||||
const followUpId = mockDirectEmailFollowUp.id;
|
||||
const followUpAction = mockDirectEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockDirectEmailFollowUp.action.properties.body,
|
||||
subject: mockDirectEmailFollowUp.action.properties.subject,
|
||||
to: mockDirectEmailFollowUp.action.properties.to,
|
||||
replyTo: mockDirectEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockResponseEmailFollowUp.action.properties.body,
|
||||
subject: mockResponseEmailFollowUp.action.properties.subject,
|
||||
to: mockResponse.data[mockResponseEmailFollowUp.action.properties.to],
|
||||
replyTo: mockResponseEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurvey,
|
||||
response: mockResponse,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("sends email when to is a question ID with valid email in array", async () => {
|
||||
const followUpId = mockContactEmailFollowUp.id;
|
||||
const followUpAction = mockContactEmailFollowUp.action;
|
||||
|
||||
await evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurveyWithContactQuestion,
|
||||
mockResponseWithContactQuestion,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(sendFollowUpEmail).toHaveBeenCalledWith({
|
||||
html: mockContactEmailFollowUp.action.properties.body,
|
||||
subject: mockContactEmailFollowUp.action.properties.subject,
|
||||
to: mockResponseWithContactQuestion.data[mockContactEmailFollowUp.action.properties.to][2],
|
||||
replyTo: mockContactEmailFollowUp.action.properties.replyTo,
|
||||
survey: mockSurveyWithContactQuestion,
|
||||
response: mockResponseWithContactQuestion,
|
||||
attachResponseData: true,
|
||||
logoUrl: "https://example.com/logo.png",
|
||||
});
|
||||
});
|
||||
|
||||
test("throws error when to value is not found in response data", async () => {
|
||||
const followUpId = "followup1";
|
||||
const followUpAction = {
|
||||
...mockSurvey.followUps![0].action,
|
||||
properties: {
|
||||
...mockSurvey.followUps![0].action.properties,
|
||||
to: "nonExistentField",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey as TSurvey,
|
||||
mockResponse as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
});
|
||||
|
||||
test("throws error when email address is invalid", async () => {
|
||||
const followUpId = mockResponseEmailFollowUp.id;
|
||||
const followUpAction = mockResponseEmailFollowUp.action;
|
||||
|
||||
const invalidResponse = {
|
||||
...mockResponse,
|
||||
data: {
|
||||
[mockResponseEmailFollowUp.action.properties.to]: "invalid-email",
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
evaluateFollowUp(
|
||||
followUpId,
|
||||
followUpAction,
|
||||
mockSurvey,
|
||||
invalidResponse,
|
||||
mockOrganization as TOrganization
|
||||
)
|
||||
).rejects.toThrow(`Email address is not valid for followup: ${followUpId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSurveyFollowUps", () => {
|
||||
test("skips follow-up when ending Id doesn't match", async () => {
|
||||
const responseWithDifferentEnding = {
|
||||
...mockResponse,
|
||||
endingId: mockEndingId2,
|
||||
};
|
||||
|
||||
const mockSurveyWithEndingFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockEndingFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithEndingFollowUp,
|
||||
responseWithDifferentEnding as TResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockEndingFollowUp.id,
|
||||
status: "skipped",
|
||||
},
|
||||
]);
|
||||
expect(sendFollowUpEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("processes follow-ups and log errors", async () => {
|
||||
const error = new Error("Test error");
|
||||
vi.mocked(sendFollowUpEmail).mockRejectedValueOnce(error);
|
||||
|
||||
const mockSurveyWithFollowUps: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockResponseEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUps,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockResponseEmailFollowUp.id,
|
||||
status: "error",
|
||||
error: "Test error",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
[`FollowUp ${mockResponseEmailFollowUp.id} failed: Test error`],
|
||||
"Follow-up processing errors"
|
||||
);
|
||||
});
|
||||
|
||||
test("successfully processes follow-ups", async () => {
|
||||
vi.mocked(sendFollowUpEmail).mockResolvedValueOnce(undefined);
|
||||
|
||||
const mockSurveyWithFollowUp: TSurvey = {
|
||||
...mockSurvey,
|
||||
followUps: [mockDirectEmailFollowUp],
|
||||
};
|
||||
|
||||
const results = await sendSurveyFollowUps(
|
||||
mockSurveyWithFollowUp,
|
||||
mockResponse,
|
||||
mockOrganization as TOrganization
|
||||
);
|
||||
|
||||
expect(results).toEqual([
|
||||
{
|
||||
followUpId: mockDirectEmailFollowUp.id,
|
||||
status: "success",
|
||||
},
|
||||
]);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { sendFollowUpEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { TSurveyFollowUpAction } from "@formbricks/database/types/survey-follow-up";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
type FollowUpResult = {
|
||||
followUpId: string;
|
||||
status: "success" | "error" | "skipped";
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const evaluateFollowUp = async (
|
||||
followUpId: string,
|
||||
followUpAction: TSurveyFollowUpAction,
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<void> => {
|
||||
const { properties } = followUpAction;
|
||||
const { to, subject, body, replyTo } = properties;
|
||||
const toValueFromResponse = response.data[to];
|
||||
const logoUrl = organization.whitelabel?.logoUrl || "";
|
||||
|
||||
// Check if 'to' is a direct email address (team member or user email)
|
||||
const parsedEmailTo = z.string().email().safeParse(to);
|
||||
if (parsedEmailTo.success) {
|
||||
// 'to' is a valid email address, send email directly
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedEmailTo.data,
|
||||
replyTo,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
logoUrl,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If not a direct email, check if it's a question ID or hidden field ID
|
||||
if (!toValueFromResponse) {
|
||||
throw new Error(`"To" value not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
|
||||
if (typeof toValueFromResponse === "string") {
|
||||
// parse this string to check for an email:
|
||||
const parsedResult = z.string().email().safeParse(toValueFromResponse);
|
||||
if (parsedResult.data) {
|
||||
// send email to this email address
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
} else if (Array.isArray(toValueFromResponse)) {
|
||||
const emailAddress = toValueFromResponse[2];
|
||||
if (!emailAddress) {
|
||||
throw new Error(`Email address not found in response data for followup: ${followUpId}`);
|
||||
}
|
||||
const parsedResult = z.string().email().safeParse(emailAddress);
|
||||
if (parsedResult.data) {
|
||||
await sendFollowUpEmail({
|
||||
html: body,
|
||||
subject,
|
||||
to: parsedResult.data,
|
||||
replyTo,
|
||||
logoUrl,
|
||||
survey,
|
||||
response,
|
||||
attachResponseData: properties.attachResponseData,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Email address is not valid for followup: ${followUpId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const sendSurveyFollowUps = async (
|
||||
survey: TSurvey,
|
||||
response: TResponse,
|
||||
organization: TOrganization
|
||||
): Promise<FollowUpResult[]> => {
|
||||
const followUpPromises = survey.followUps.map(async (followUp): Promise<FollowUpResult> => {
|
||||
const { trigger } = followUp;
|
||||
|
||||
// Check if we should skip this follow-up based on ending IDs
|
||||
if (trigger.properties) {
|
||||
const { endingIds } = trigger.properties;
|
||||
const { endingId } = response;
|
||||
|
||||
if (!endingId || !endingIds.includes(endingId)) {
|
||||
return Promise.resolve({
|
||||
followUpId: followUp.id,
|
||||
status: "skipped",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return evaluateFollowUp(followUp.id, followUp.action, survey, response, organization)
|
||||
.then(() => ({
|
||||
followUpId: followUp.id,
|
||||
status: "success" as const,
|
||||
}))
|
||||
.catch((error) => ({
|
||||
followUpId: followUp.id,
|
||||
status: "error" as const,
|
||||
error: error instanceof Error ? error.message : "Something went wrong",
|
||||
}));
|
||||
});
|
||||
|
||||
const followUpResults = await Promise.all(followUpPromises);
|
||||
|
||||
// Log all errors
|
||||
const errors = followUpResults
|
||||
.filter((result): result is FollowUpResult & { status: "error" } => result.status === "error")
|
||||
.map((result) => `FollowUp ${result.followUpId} failed: ${result.error}`);
|
||||
|
||||
if (errors.length > 0) {
|
||||
logger.error(errors, "Follow-up processing errors");
|
||||
}
|
||||
|
||||
return followUpResults;
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sendSurveyFollowUps } from "@/app/api/(internal)/pipeline/lib/survey-follow-up";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
@@ -11,7 +10,8 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -164,11 +164,15 @@ export const POST = async (request: Request) => {
|
||||
select: { email: true, locale: true },
|
||||
});
|
||||
|
||||
// send follow up emails
|
||||
const surveyFollowUpsPermission = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
|
||||
if (surveyFollowUpsPermission) {
|
||||
await sendSurveyFollowUps(survey, response, organization);
|
||||
if (survey.followUps?.length > 0) {
|
||||
// send follow up emails
|
||||
const followUpsResult = await sendFollowUpsForResponse(response.id);
|
||||
if (!followUpsResult.ok) {
|
||||
const { error: followUpsError } = followUpsResult;
|
||||
if (followUpsError.code !== FollowUpSendError.FOLLOW_UP_NOT_ALLOWED) {
|
||||
logger.error({ error: followUpsError }, `Failed to send follow-up emails for survey ${surveyId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emailPromises = usersWithNotifications.map((user) =>
|
||||
|
||||
@@ -254,7 +254,7 @@ describe("getEnvironmentState", () => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
|
||||
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue([mockSurveys[0]]); // Only return the app, inProgress survey
|
||||
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
|
||||
});
|
||||
|
||||
@@ -99,12 +99,8 @@ export const getEnvironmentState = async (
|
||||
getActionClassesForEnvironmentState(environmentId),
|
||||
]);
|
||||
|
||||
const filteredSurveys = surveys.filter(
|
||||
(survey) => survey.type === "app" && survey.status === "inProgress"
|
||||
);
|
||||
|
||||
const data: TJsEnvironmentState["data"] = {
|
||||
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
|
||||
surveys: !isMonthlyResponsesLimitReached ? surveys : [],
|
||||
actionClasses,
|
||||
project: project,
|
||||
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
|
||||
|
||||
@@ -100,8 +100,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]);
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object), // Check if select is called, specific fields are in the original code
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockPrismaSurvey);
|
||||
expect(result).toEqual([mockTransformedSurvey]);
|
||||
@@ -114,8 +120,14 @@ describe("getSurveysForEnvironmentState", () => {
|
||||
const result = await getSurveysForEnvironmentState(environmentId);
|
||||
|
||||
expect(prisma.survey.findMany).toHaveBeenCalledWith({
|
||||
where: { environmentId },
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 30,
|
||||
});
|
||||
expect(transformPrismaSurvey).not.toHaveBeenCalled();
|
||||
expect(result).toEqual([]);
|
||||
|
||||
@@ -20,7 +20,13 @@ export const getSurveysForEnvironmentState = reactCache(
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 30,
|
||||
select: {
|
||||
id: true,
|
||||
welcomeCard: true,
|
||||
|
||||
@@ -57,6 +57,10 @@ export const PUT = async (
|
||||
return handleDatabaseError(error, request.url, endpoint, responseId);
|
||||
}
|
||||
|
||||
if (response.finished) {
|
||||
return responses.badRequestResponse("Response is already finished", undefined, true);
|
||||
}
|
||||
|
||||
// get survey to get environmentId
|
||||
let survey;
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const responseSelection = {
|
||||
singleUseId: true,
|
||||
language: true,
|
||||
displayId: true,
|
||||
endingId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -7,39 +7,133 @@ export const GET = async (req: NextRequest) => {
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div tw={`flex flex-col w-full h-full items-center bg-[${brandColor}]/75 rounded-xl `}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
<div
|
||||
tw="flex flex-col w-[80%] h-[60%] bg-white rounded-xl mt-13 absolute left-12 top-3 opacity-20"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[84%] h-[60%] bg-white rounded-xl mt-12 absolute top-5 left-13 border-2 opacity-60"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
tw="flex flex-col w-[85%] h-[67%] items-center bg-white rounded-xl mt-8 absolute top-[2.3rem] left-14"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div tw="flex flex-col w-full">
|
||||
<div tw="flex flex-col md:flex-row w-full md:items-center justify-between ">
|
||||
<div tw="flex flex-col px-8">
|
||||
<h2 tw="flex flex-col text-[8] sm:text-4xl font-bold tracking-tight text-slate-900 text-left mt-15">
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div tw="flex justify-end mr-10 ">
|
||||
<div tw="flex rounded-2xl absolute -right-2 mt-2">
|
||||
<a tw={`rounded-xl border border-transparent bg-[${brandColor}] h-18 w-38 opacity-50`}></a>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div tw="flex rounded-2xl shadow ">
|
||||
<a
|
||||
tw={`flex items-center justify-center rounded-xl border border-transparent bg-[${brandColor}] text-2xl text-white h-18 w-38`}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,7 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
endingId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
@@ -64,7 +65,8 @@ export const createResponse = async (responseInput: TResponseInputV2): Promise<T
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
finished,
|
||||
endingId,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/respon
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { Organization } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -40,6 +41,13 @@ vi.mock("@formbricks/logger", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
}));
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-1",
|
||||
createdAt: new Date(),
|
||||
@@ -206,4 +214,119 @@ describe("checkSurveyValidity", () => {
|
||||
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and singleUseId is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", { ...mockResponseInput });
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if singleUse is enabled and meta.url is missing", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: {},
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if meta.url is invalid", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url: "not-a-url" },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Invalid URL in response metadata",
|
||||
expect.objectContaining({ surveyId: survey.id, environmentId: "env-1" })
|
||||
);
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if suId is missing from url", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?foo=bar";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if isEncrypted and decrypted suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("decrypted-id");
|
||||
const resultEncryptedMismatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(resultEncryptedMismatch).toBeInstanceOf(Response);
|
||||
expect(resultEncryptedMismatch?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return badRequestResponse if not encrypted and suId does not match singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-2";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect(result?.status).toBe(400);
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId: "env-1",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, not encrypted, and suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
|
||||
const url = "https://example.com/?suId=su-1";
|
||||
const result = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if singleUse is enabled, encrypted, and decrypted suId matches singleUseId", async () => {
|
||||
const survey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: true } };
|
||||
const url = "https://example.com/?suId=encrypted-id";
|
||||
vi.mocked(symmetricDecrypt).mockReturnValue("su-1");
|
||||
const _resultEncryptedMatch = await checkSurveyValidity(survey, "env-1", {
|
||||
...mockResponseInput,
|
||||
singleUseId: "su-1",
|
||||
meta: { url },
|
||||
});
|
||||
expect(symmetricDecrypt).toHaveBeenCalledWith("encrypted-id", "test-key");
|
||||
expect(_resultEncryptedMatch).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[envi
|
||||
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
import { symmetricDecrypt } from "@/lib/crypto";
|
||||
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -24,6 +26,55 @@ export const checkSurveyValidity = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.type === "link" && survey.singleUse?.enabled) {
|
||||
if (!responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!responseInput.meta?.url) {
|
||||
return responses.badRequestResponse("Missing or invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(responseInput.meta.url);
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid URL in response metadata", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
const suId = url.searchParams.get("suId");
|
||||
if (!suId) {
|
||||
return responses.badRequestResponse("Missing single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (survey.singleUse.isEncrypted) {
|
||||
const decryptedSuId = symmetricDecrypt(suId, ENCRYPTION_KEY);
|
||||
if (decryptedSuId !== responseInput.singleUseId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
} else if (responseInput.singleUseId !== suId) {
|
||||
return responses.badRequestResponse("Invalid single use id", {
|
||||
surveyId: survey.id,
|
||||
environmentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (survey.recaptcha?.enabled) {
|
||||
if (!responseInput.recaptchaToken) {
|
||||
logger.error("Missing recaptcha token");
|
||||
|
||||
@@ -1,70 +1,99 @@
|
||||
import * as constants from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { loginLimiter, signupLimiter } from "./bucket";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
REDIS_HTTP_URL: undefined,
|
||||
LOGIN_RATE_LIMIT: {
|
||||
interval: 15 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SIGNUP_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
VERIFY_EMAIL_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
FORGET_PASSWORD_RATE_LIMIT: {
|
||||
interval: 60 * 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
CLIENT_SIDE_API_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SHARE_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT: {
|
||||
interval: 60,
|
||||
allowedPerInterval: 5,
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/utils/rate-limit", () => ({ rateLimit: vi.fn() }));
|
||||
|
||||
describe("Rate Limiters", () => {
|
||||
describe("bucket middleware rate limiters", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
const mockedRateLimit = rateLimit as unknown as Mock;
|
||||
mockedRateLimit.mockImplementation((config) => config);
|
||||
});
|
||||
|
||||
test("loginLimiter allows requests within limit", () => {
|
||||
const token = "test-token-1";
|
||||
// Should not throw for first request
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
|
||||
const { loginLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(loginLimiter).toEqual({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("loginLimiter throws when limit exceeded", () => {
|
||||
const token = "test-token-2";
|
||||
// Make multiple requests to exceed the limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
}
|
||||
// Next request should throw
|
||||
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
|
||||
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
|
||||
const { signupLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(signupLimiter).toEqual({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("different limiters use different counters", () => {
|
||||
const token = "test-token-3";
|
||||
// Exceed login limit
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(() => loginLimiter(token)).not.toThrow();
|
||||
}
|
||||
// Should throw for login
|
||||
expect(() => loginLimiter(token)).toThrow("Rate limit exceeded");
|
||||
// Should still be able to use signup limiter
|
||||
expect(() => signupLimiter(token)).not.toThrow();
|
||||
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
|
||||
const { verifyEmailLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(verifyEmailLimiter).toEqual({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
|
||||
const { forgotPasswordLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(forgotPasswordLimiter).toEqual({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
|
||||
const { clientSideApiEndpointsLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(clientSideApiEndpointsLimiter).toEqual({
|
||||
interval: constants.CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
|
||||
const { shareUrlLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(shareUrlLimiter).toEqual({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
|
||||
const { syncUserIdentificationLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(syncUserIdentificationLimiter).toEqual({
|
||||
interval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { rateLimit } from "@/app/middleware/rate-limit";
|
||||
import {
|
||||
CLIENT_SIDE_API_RATE_LIMIT,
|
||||
FORGET_PASSWORD_RATE_LIMIT,
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
|
||||
VERIFY_EMAIL_RATE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("SentryProvider", () => {
|
||||
expect(initSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1,
|
||||
tracesSampleRate: 0,
|
||||
debug: false,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 0.1,
|
||||
@@ -81,6 +81,26 @@ describe("SentryProvider", () => {
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Test Content");
|
||||
});
|
||||
|
||||
test("does not reinitialize Sentry when props change after initial render", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
const { rerender } = render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(
|
||||
<SentryProvider sentryDsn="https://newDsn@o0.ingest.sentry.io/0" isEnabled={false}>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
expect(initSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
@@ -109,4 +129,36 @@ describe("SentryProvider", () => {
|
||||
const hintWithoutError = { originalException: undefined };
|
||||
expect(beforeSend(dummyEvent, hintWithoutError)).toEqual(dummyEvent);
|
||||
});
|
||||
|
||||
test("processes beforeSend correctly when hint.originalException is not an Error object", () => {
|
||||
const initSpy = vi.spyOn(Sentry, "init").mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<SentryProvider sentryDsn={sentryDsn} isEnabled>
|
||||
<div data-testid="child">Test Content</div>
|
||||
</SentryProvider>
|
||||
);
|
||||
|
||||
const config = initSpy.mock.calls[0][0];
|
||||
expect(config).toHaveProperty("beforeSend");
|
||||
const beforeSend = config.beforeSend;
|
||||
|
||||
if (!beforeSend) {
|
||||
throw new Error("beforeSend is not defined");
|
||||
}
|
||||
|
||||
const dummyEvent = { some: "event" } as unknown as Sentry.ErrorEvent;
|
||||
|
||||
const hintWithString = { originalException: "string exception" };
|
||||
expect(() => beforeSend(dummyEvent, hintWithString)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithString)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNumber = { originalException: 123 };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNumber)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNumber)).toEqual(dummyEvent);
|
||||
|
||||
const hintWithNull = { originalException: null };
|
||||
expect(() => beforeSend(dummyEvent, hintWithNull)).not.toThrow();
|
||||
expect(beforeSend(dummyEvent, hintWithNull)).toEqual(dummyEvent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,8 @@ export const SentryProvider = ({ children, sentryDsn, isEnabled }: SentryProvide
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
// No tracing while Sentry doesn't update to telemetry 2.0.0 - https://github.com/getsentry/sentry-javascript/issues/15737
|
||||
tracesSampleRate: 0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
|
||||
@@ -150,7 +150,12 @@ export const createActionClass = async (
|
||||
...actionClassInput,
|
||||
environment: { connect: { id: environmentId } },
|
||||
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
|
||||
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
|
||||
noCodeConfig:
|
||||
actionClassInput.type === "noCode"
|
||||
? actionClassInput.noCodeConfig === null
|
||||
? undefined
|
||||
: actionClassInput.noCodeConfig
|
||||
: undefined,
|
||||
},
|
||||
select: selectActionClass,
|
||||
});
|
||||
@@ -193,7 +198,12 @@ export const updateActionClass = async (
|
||||
...actionClassInput,
|
||||
environment: { connect: { id: environmentId } },
|
||||
key: actionClassInput.type === "code" ? actionClassInput.key : undefined,
|
||||
noCodeConfig: actionClassInput.type === "noCode" ? actionClassInput.noCodeConfig || {} : undefined,
|
||||
noCodeConfig:
|
||||
actionClassInput.type === "noCode"
|
||||
? actionClassInput.noCodeConfig === null
|
||||
? undefined
|
||||
: actionClassInput.noCodeConfig
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
...selectActionClass,
|
||||
@@ -212,7 +222,6 @@ export const updateActionClass = async (
|
||||
id: result.id,
|
||||
});
|
||||
|
||||
// @ts-expect-error
|
||||
const surveyIds = result.surveyTriggers.map((survey) => survey.surveyId);
|
||||
for (const surveyId of surveyIds) {
|
||||
surveyCache.revalidate({
|
||||
|
||||
@@ -282,4 +282,6 @@ export const SENTRY_DSN = env.SENTRY_DSN;
|
||||
|
||||
export const PROMETHEUS_ENABLED = env.PROMETHEUS_ENABLED === "1";
|
||||
|
||||
export const DISABLE_USER_MANAGEMENT = env.DISABLE_USER_MANAGEMENT === "1";
|
||||
export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ?? "manager";
|
||||
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user