Compare commits
54 Commits
v3.8.6
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5610c8128 | ||
|
|
3b1cddb9ce | ||
|
|
bd22aaaa86 | ||
|
|
e0e42d2eed | ||
|
|
616210f1bf | ||
|
|
ff2e7f6cc7 | ||
|
|
d1ce037f7d | ||
|
|
91f87f4b7b | ||
|
|
61657b9f9a | ||
|
|
476d032642 | ||
|
|
7538e570c5 | ||
|
|
66fcf4b79b | ||
|
|
21371b1815 | ||
|
|
a53c13d6ed | ||
|
|
1a0c6e72b2 | ||
|
|
ba7c8b79b1 | ||
|
|
d7b504eed0 | ||
|
|
a1df10eb09 | ||
|
|
92be409d4f | ||
|
|
665c7c6bf1 | ||
|
|
6c2ff7ee08 | ||
|
|
295a1bf402 | ||
|
|
3e6f558b08 | ||
|
|
aad5a59e82 | ||
|
|
36d02480b2 | ||
|
|
99454ac57b | ||
|
|
e2915f878e | ||
|
|
710a813e9b | ||
|
|
8bdb818995 | ||
|
|
20466c3800 | ||
|
|
faf6c2d062 | ||
|
|
a760a3c341 | ||
|
|
94e6d2f215 | ||
|
|
a6f1c0f63d | ||
|
|
c653996cbb | ||
|
|
da44fef89d | ||
|
|
4dc2c5e3df | ||
|
|
1797c2ae20 | ||
|
|
3b5da01c0a | ||
|
|
0f1bdce002 | ||
|
|
7c8f3e826f | ||
|
|
f21d63bb55 | ||
|
|
f223bb3d3f | ||
|
|
51001d07b6 | ||
|
|
a9eedd3c7a | ||
|
|
b0aa08fe4e | ||
|
|
8d45d24d55 | ||
|
|
8c1b9f81b9 | ||
|
|
71fad1c22b | ||
|
|
292266c597 | ||
|
|
54e589a6a0 | ||
|
|
fb3f425c27 | ||
|
|
1aaa30c6e9 | ||
|
|
8611410b21 |
24
.env.example
@@ -93,10 +93,6 @@ EMAIL_VERIFICATION_DISABLED=1
|
||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||
PASSWORD_RESET_DISABLED=1
|
||||
|
||||
# Signup. Disable the ability for new users to create an account.
|
||||
# Note: This variable is only available to the SaaS setup of Formbricks Cloud. Signup is disable by default for self-hosting.
|
||||
# SIGNUP_DISABLED=1
|
||||
|
||||
# Email login. Disable the ability for users to login with email.
|
||||
# EMAIL_AUTH_DISABLED=1
|
||||
|
||||
@@ -120,6 +116,10 @@ IMPRINT_ADDRESS=
|
||||
# TURNSTILE_SITE_KEY=
|
||||
# TURNSTILE_SECRET_KEY=
|
||||
|
||||
# Google reCAPTCHA v3 keys
|
||||
RECAPTCHA_SITE_KEY=
|
||||
RECAPTCHA_SECRET_KEY=
|
||||
|
||||
# Configure Github Login
|
||||
GITHUB_ID=
|
||||
GITHUB_SECRET=
|
||||
@@ -154,10 +154,6 @@ NOTION_OAUTH_CLIENT_SECRET=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# Configure Formbricks usage within Formbricks
|
||||
FORMBRICKS_API_HOST=
|
||||
FORMBRICKS_ENVIRONMENT_ID=
|
||||
|
||||
# Oauth credentials for Google sheet integration
|
||||
GOOGLE_SHEETS_CLIENT_ID=
|
||||
GOOGLE_SHEETS_CLIENT_SECRET=
|
||||
@@ -176,8 +172,9 @@ 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_ID=
|
||||
# DEFAULT_ORGANIZATION_ROLE=owner
|
||||
# AUTH_SSO_DEFAULT_TEAM_ID=
|
||||
# AUTH_SKIP_INVITE_FOR_SSO=
|
||||
|
||||
# Send new users to Brevo
|
||||
# BREVO_API_KEY=
|
||||
@@ -206,12 +203,6 @@ UNKEY_ROOT_KEY=
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
# Azure AI settings
|
||||
# AI_AZURE_RESSOURCE_NAME=
|
||||
# AI_AZURE_API_KEY=
|
||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -224,3 +215,6 @@ UNKEY_ROOT_KEY=
|
||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||
# 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
|
||||
8
.github/copilot-instructions.md
vendored
@@ -2,6 +2,7 @@
|
||||
|
||||
When generating test files inside the "/app/web" path, follow these rules:
|
||||
|
||||
- You are an experienced senior software engineer
|
||||
- Use vitest
|
||||
- Ensure 100% code coverage
|
||||
- Add as few comments as possible
|
||||
@@ -10,8 +11,10 @@ When generating test files inside the "/app/web" path, follow these rules:
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file. Do this only when the test file is created.
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
- When using "screen.getByText" check for the tolgee string if it is being used in the file.
|
||||
- When mocking data check if the properties added are part of the type of the object being mocked. Don't add properties that are not part of the type.
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
@@ -21,6 +24,7 @@ afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
|
||||
- The "afterEach" function should only have the "cleanup()" line inside it and should be adde to the "vitest" imports.
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
- You don't need to mock @tolgee/react
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/build-web.yml
vendored
@@ -13,11 +13,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Build & Cache Web Binaries
|
||||
|
||||
2
.github/workflows/chromatic.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: 'Checkout Repository'
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- name: 'Dependency Review'
|
||||
uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0
|
||||
uses: actions/dependency-review-action@ce3cf9537a52e8119d91fd484ab5b8a807627bf8 # v4.6.0
|
||||
|
||||
40
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -12,6 +12,13 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -23,6 +30,10 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -33,7 +44,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
@@ -48,6 +66,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -58,7 +78,23 @@ jobs:
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
helmfile-args: apply
|
||||
helmfile-args: apply -l environment=prod
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
helmfile-args: apply -l environment=stage
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
4
.github/workflows/e2e.yml
vendored
@@ -46,11 +46,11 @@ jobs:
|
||||
--health-retries=5
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
|
||||
1
.github/workflows/formbricks-release.yml
vendored
@@ -31,3 +31,4 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
|
||||
2
.github/workflows/labeler.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/lint.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/pr.yml
vendored
@@ -51,7 +51,7 @@ jobs:
|
||||
statuses: write
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: fail if conditional jobs failed
|
||||
|
||||
56
.github/workflows/release-changesets.yml
vendored
@@ -1,56 +0,0 @@
|
||||
name: Release Changesets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
#push:
|
||||
# branches:
|
||||
# - main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
||||
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2
|
||||
with:
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9
|
||||
with:
|
||||
# This expects you to have a script called release which does a build for your packages and calls changeset publish
|
||||
publish: pnpm release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -31,12 +31,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
8
.github/workflows/release-docker-github.yml
vendored
@@ -38,12 +38,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
@@ -65,13 +65,13 @@ jobs:
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 # v3.5.0
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
2
.github/workflows/release-helm-chart.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
4
.github/workflows/scorecard.yml
vendored
@@ -35,12 +35,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/semantic-pull-requests.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/sonarqube.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -26,13 +26,20 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||
with:
|
||||
4
.github/workflows/test.yml
vendored
@@ -14,11 +14,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
2
.github/workflows/tolgee.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.action == 'opened'
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
|
||||
@@ -16,6 +16,6 @@ if [ -f branch.json ]; then
|
||||
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
||||
else
|
||||
pnpm run tolgee-pull
|
||||
git add packages/lib/messages
|
||||
git add apps/web/locales
|
||||
fi
|
||||
fi
|
||||
@@ -4,33 +4,33 @@
|
||||
"patterns": ["./apps/web/**/*.ts?(x)"],
|
||||
"projectId": 10304,
|
||||
"pull": {
|
||||
"path": "./packages/lib/messages"
|
||||
"path": "./apps/web/locales"
|
||||
},
|
||||
"push": {
|
||||
"files": [
|
||||
{
|
||||
"language": "en-US",
|
||||
"path": "./packages/lib/messages/en-US.json"
|
||||
"path": "./apps/web/locales/en-US.json"
|
||||
},
|
||||
{
|
||||
"language": "de-DE",
|
||||
"path": "./packages/lib/messages/de-DE.json"
|
||||
"path": "./apps/web/locales/de-DE.json"
|
||||
},
|
||||
{
|
||||
"language": "fr-FR",
|
||||
"path": "./packages/lib/messages/fr-FR.json"
|
||||
"path": "./apps/web/locales/fr-FR.json"
|
||||
},
|
||||
{
|
||||
"language": "pt-BR",
|
||||
"path": "./packages/lib/messages/pt-BR.json"
|
||||
"path": "./apps/web/locales/pt-BR.json"
|
||||
},
|
||||
{
|
||||
"language": "zh-Hant-TW",
|
||||
"path": "./packages/lib/messages/zh-Hant-TW.json"
|
||||
"path": "./apps/web/locales/zh-Hant-TW.json"
|
||||
},
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./packages/lib/messages/pt-PT.json"
|
||||
"path": "./apps/web/locales/pt-PT.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
2
LICENSE
@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
|
||||
EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/react.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
35
apps/demo-react-native/.gitignore
vendored
@@ -1,35 +0,0 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
|
||||
# Native
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"foregroundImage": "./assets/adaptive-icon.png"
|
||||
}
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"icon": "./assets/icon.png",
|
||||
"ios": {
|
||||
"infoPlist": {
|
||||
"NSCameraUsageDescription": "Take pictures for certain activities.",
|
||||
"NSMicrophoneUsageDescription": "Need microphone access for recording videos.",
|
||||
"NSPhotoLibraryUsageDescription": "Select pictures for certain activities."
|
||||
},
|
||||
"supportsTablet": true
|
||||
},
|
||||
"jsEngine": "hermes",
|
||||
"name": "react-native-demo",
|
||||
"newArchEnabled": true,
|
||||
"orientation": "portrait",
|
||||
"slug": "react-native-demo",
|
||||
"splash": {
|
||||
"backgroundColor": "#ffffff",
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain"
|
||||
},
|
||||
"userInterfaceStyle": "light",
|
||||
"version": "1.0.0",
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 46 KiB |
@@ -1,6 +0,0 @@
|
||||
module.exports = function babel(api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { registerRootComponent } from "expo";
|
||||
import { LogBox } from "react-native";
|
||||
import App from "./src/app";
|
||||
|
||||
registerRootComponent(App);
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
@@ -1,21 +0,0 @@
|
||||
// Learn more https://docs.expo.io/guides/customizing-metro
|
||||
const path = require("node:path");
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
|
||||
// Find the workspace root, this can be replaced with `find-yarn-workspace-root`
|
||||
const workspaceRoot = path.resolve(__dirname, "../..");
|
||||
const projectRoot = __dirname;
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
config.watchFolders = [workspaceRoot];
|
||||
// 2. Let Metro know where to resolve packages, and in what order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, "node_modules"),
|
||||
path.resolve(workspaceRoot, "node_modules"),
|
||||
];
|
||||
// 3. Force Metro to resolve (sub)dependencies only from the `nodeModulesPaths`
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/demo-react-native",
|
||||
"version": "1.0.0",
|
||||
"main": "./index.js",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo start --android",
|
||||
"ios": "expo start --ios",
|
||||
"web": "expo start --web",
|
||||
"eject": "expo eject",
|
||||
"clean": "rimraf .turbo node_modules .expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@formbricks/react-native": "workspace:*",
|
||||
"@react-native-async-storage/async-storage": "2.1.0",
|
||||
"expo": "52.0.28",
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@types/react": "18.3.18",
|
||||
"typescript": "5.7.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import React, { type JSX } from "react";
|
||||
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
|
||||
import Formbricks, {
|
||||
logout,
|
||||
setAttribute,
|
||||
setAttributes,
|
||||
setLanguage,
|
||||
setUserId,
|
||||
track,
|
||||
} from "@formbricks/react-native";
|
||||
|
||||
LogBox.ignoreAllLogs();
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
if (!process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID) {
|
||||
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
|
||||
}
|
||||
|
||||
if (!process.env.EXPO_PUBLIC_APP_URL) {
|
||||
throw new Error("EXPO_PUBLIC_APP_URL is required");
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Formbricks React Native SDK Demo</Text>
|
||||
|
||||
<View
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}>
|
||||
<Button
|
||||
title="Trigger Code Action"
|
||||
onPress={() => {
|
||||
track("code").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error tracking event:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Id"
|
||||
onPress={() => {
|
||||
setUserId("random-user-id").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user id:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributess (multiple)"
|
||||
onPress={() => {
|
||||
setAttributes({
|
||||
testAttr: "attr-test",
|
||||
testAttr2: "attr-test-2",
|
||||
testAttr3: "attr-test-3",
|
||||
testAttr4: "attr-test-4",
|
||||
}).catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set User Attributes (single)"
|
||||
onPress={() => {
|
||||
setAttribute("testSingleAttr", "testSingleAttr").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting user attributes:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Logout"
|
||||
onPress={() => {
|
||||
logout().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error logging out:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Set Language (de)"
|
||||
onPress={() => {
|
||||
setLanguage("de").catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console -- logging is allowed in demo apps
|
||||
console.error("Error setting language:", error);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<StatusBar style="auto" />
|
||||
|
||||
<Formbricks
|
||||
appUrl={process.env.EXPO_PUBLIC_APP_URL as string}
|
||||
environmentId={process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: "#fff",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
},
|
||||
"extends": "expo/tsconfig.base"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=http://localhost:3000
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=YOUR_ENVIRONMENT_ID
|
||||
|
||||
# Copy the environment ID for the URL of your Formbricks App and
|
||||
# paste it above to connect your Formbricks App with the Demo App.
|
||||
@@ -1,7 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ["@formbricks/eslint-config/next.js"],
|
||||
parserOptions: {
|
||||
project: "tsconfig.json",
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
};
|
||||
36
apps/demo/.gitignore
vendored
@@ -1,36 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Sidebar } from "./sidebar";
|
||||
|
||||
export function LayoutApp({ children }: { children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<Sidebar />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col lg:pl-64">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import {
|
||||
ClockIcon,
|
||||
CogIcon,
|
||||
CreditCardIcon,
|
||||
FileBarChartIcon,
|
||||
HelpCircleIcon,
|
||||
HomeIcon,
|
||||
ScaleIcon,
|
||||
ShieldCheckIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { classNames } from "../lib/utils";
|
||||
|
||||
const navigation = [
|
||||
{ name: "Home", href: "#", icon: HomeIcon, current: true },
|
||||
{ name: "History", href: "#", icon: ClockIcon, current: false },
|
||||
{ name: "Balances", href: "#", icon: ScaleIcon, current: false },
|
||||
{ name: "Cards", href: "#", icon: CreditCardIcon, current: false },
|
||||
{ name: "Recipients", href: "#", icon: UsersIcon, current: false },
|
||||
{ name: "Reports", href: "#", icon: FileBarChartIcon, current: false },
|
||||
];
|
||||
const secondaryNavigation = [
|
||||
{ name: "Settings", href: "#", icon: CogIcon },
|
||||
{ name: "Help", href: "#", icon: HelpCircleIcon },
|
||||
{ name: "Privacy", href: "#", icon: ShieldCheckIcon },
|
||||
];
|
||||
|
||||
export function Sidebar(): React.JSX.Element {
|
||||
return (
|
||||
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
|
||||
<nav
|
||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||
aria-label="Sidebar">
|
||||
<div className="space-y-1 px-2">
|
||||
{navigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
|
||||
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
|
||||
)}
|
||||
aria-current={item.current ? "page" : undefined}>
|
||||
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 pt-6">
|
||||
<div className="space-y-1 px-2">
|
||||
{secondaryNavigation.map((item) => (
|
||||
<a
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
|
||||
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
|
||||
{item.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin '@tailwindcss/forms';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
||||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function classNames(...classes: string[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
5
apps/demo/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
@@ -1,17 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "tailwindui.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"name": "@formbricks/demo",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
"dev": "next dev -p 3002 --turbopack",
|
||||
"go": "next dev -p 3002 --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"@tailwindcss/forms": "0.5.9",
|
||||
"@tailwindcss/postcss": "4.1.3",
|
||||
"lucide-react": "0.486.0",
|
||||
"next": "15.2.4",
|
||||
"postcss": "8.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwindcss": "4.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { AppProps } from "next/app";
|
||||
import Head from "next/head";
|
||||
import "../globals.css";
|
||||
|
||||
export default function App({ Component, pageProps }: AppProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Demo App</title>
|
||||
</Head>
|
||||
{(!process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID ||
|
||||
!process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) && (
|
||||
<div className="w-full bg-red-500 p-3 text-center text-sm text-white">
|
||||
Please set Formbricks environment variables in apps/demo/.env
|
||||
</div>
|
||||
)}
|
||||
<Component {...pageProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Head, Html, Main, NextScript } from "next/document";
|
||||
|
||||
export default function Document(): React.JSX.Element {
|
||||
return (
|
||||
<Html lang="en" className="h-full bg-slate-50">
|
||||
<Head />
|
||||
<body className="h-full">
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect, useState } from "react";
|
||||
import formbricks from "@formbricks/js";
|
||||
import fbsetup from "../public/fb-setup.png";
|
||||
|
||||
declare const window: Window;
|
||||
|
||||
export default function AppPage(): React.JSX.Element {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const router = useRouter();
|
||||
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
|
||||
const userAttributes = {
|
||||
"Attribute 1": "one",
|
||||
"Attribute 2": "two",
|
||||
"Attribute 3": "three",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (darkMode) {
|
||||
document.body.classList.add("dark");
|
||||
} else {
|
||||
document.body.classList.remove("dark");
|
||||
}
|
||||
}, [darkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const initFormbricks = () => {
|
||||
// enable Formbricks debug mode by adding formbricksDebug=true GET parameter
|
||||
const addFormbricksDebugParam = (): void => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (!urlParams.has("formbricksDebug")) {
|
||||
urlParams.set("formbricksDebug", "true");
|
||||
const newUrl = `${window.location.pathname}?${urlParams.toString()}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
};
|
||||
|
||||
addFormbricksDebugParam();
|
||||
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
void formbricks.setup({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
});
|
||||
}
|
||||
|
||||
// Connect next.js router to Formbricks
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
const handleRouteChange = formbricks.registerRouteChange;
|
||||
|
||||
router.events.on("routeChangeComplete", () => {
|
||||
void handleRouteChange();
|
||||
});
|
||||
|
||||
return () => {
|
||||
router.events.off("routeChangeComplete", () => {
|
||||
void handleRouteChange();
|
||||
});
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
initFormbricks();
|
||||
}, [router.events]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white px-12 py-6 dark:bg-slate-800">
|
||||
<div className="flex flex-col justify-between md:flex-row">
|
||||
<div className="flex flex-col items-center gap-2 sm:flex-row">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">
|
||||
Formbricks In-product Survey Demo App
|
||||
</h1>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
This app helps you test your app surveys. You can create and test user actions, create and
|
||||
update user attributes, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 rounded-lg bg-slate-200 px-6 py-1 dark:bg-slate-700 dark:text-slate-100"
|
||||
onClick={() => {
|
||||
setDarkMode(!darkMode);
|
||||
}}>
|
||||
{darkMode ? "Toggle Light Mode" : "Toggle Dark Mode"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="my-4 grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">1. Setup .env</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
||||
</p>
|
||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
|
||||
|
||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
||||
<p className="mb-1 sm:mr-2 sm:mb-0">You're connected with env:</p>
|
||||
<div className="flex items-center">
|
||||
<strong className="w-32 truncate sm:w-auto">
|
||||
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
|
||||
</strong>
|
||||
<span className="relative ml-2 flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-green-500" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white">2. Widget Logs</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
Look at the logs to understand how the widget works.{" "}
|
||||
<strong className="dark:text-white">Open your browser console</strong> to see the logs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:grid md:grid-cols-3">
|
||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||
<h3 className="text-lg font-semibold dark:text-white">
|
||||
Set a user ID / pull data from Formbricks app
|
||||
</h3>
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
|
||||
the local state gets <strong>updated with the user state</strong>.
|
||||
</p>
|
||||
<button
|
||||
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setUserId(userId);
|
||||
}}>
|
||||
Set user ID
|
||||
</button>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and
|
||||
try again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
No-Code Action
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends a{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500"
|
||||
target="_blank">
|
||||
No Code Action
|
||||
</a>{" "}
|
||||
as long as you created it beforehand in the Formbricks App.{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="underline dark:text-blue-500">
|
||||
Here are instructions on how to do it.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setAttribute("Plan", "Free");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Set Plan to 'Free'
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500">
|
||||
attribute
|
||||
</a>{" "}
|
||||
'Plan' to 'Free'. If the attribute does not exist, it creates it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setAttribute("Plan", "Paid");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Set Plan to 'Paid'
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500">
|
||||
attribute
|
||||
</a>{" "}
|
||||
'Plan' to 'Paid'. If the attribute does not exist, it creates it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setEmail("test@web.com");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Set Email
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500">
|
||||
user email
|
||||
</a>{" "}
|
||||
'test@web.com'
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setAttributes(userAttributes);
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Set Multiple Attributes
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500">
|
||||
user attributes
|
||||
</a>{" "}
|
||||
to 'one', 'two', 'three'.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void formbricks.setLanguage("de");
|
||||
}}
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||
Set Language to 'de'
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sets the{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500">
|
||||
language
|
||||
</a>{" "}
|
||||
to 'de'.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
void formbricks.track("code");
|
||||
}}>
|
||||
Code Action
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button sends a{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
|
||||
rel="noopener noreferrer"
|
||||
className="underline dark:text-blue-500"
|
||||
target="_blank">
|
||||
Code Action
|
||||
</a>{" "}
|
||||
as long as you created it beforehand in the Formbricks App.{" "}
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
className="underline dark:text-blue-500">
|
||||
Here are instructions on how to do it.
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||
onClick={() => {
|
||||
void formbricks.logout();
|
||||
}}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||
This button logs out the user and syncs the local state with Formbricks. (Only works if a
|
||||
userId is set)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="31" fill="none"><g opacity=".9"><path fill="url(#a)" d="M13 .4v29.3H7V6.3h-.2L0 10.5V5L7.2.4H13Z"/><path fill="url(#b)" d="M28.8 30.1c-2.2 0-4-.3-5.7-1-1.7-.8-3-1.8-4-3.1a7.7 7.7 0 0 1-1.4-4.6h6.2c0 .8.3 1.4.7 2 .4.5 1 .9 1.7 1.2.7.3 1.6.4 2.5.4 1 0 1.7-.2 2.5-.5.7-.3 1.3-.8 1.7-1.4.4-.6.6-1.2.6-2s-.2-1.5-.7-2.1c-.4-.6-1-1-1.8-1.4-.8-.4-1.8-.5-2.9-.5h-2.7v-4.6h2.7a6 6 0 0 0 2.5-.5 4 4 0 0 0 1.7-1.3c.4-.6.6-1.3.6-2a3.5 3.5 0 0 0-2-3.3 5.6 5.6 0 0 0-4.5 0 4 4 0 0 0-1.7 1.2c-.4.6-.6 1.2-.6 2h-6c0-1.7.6-3.2 1.5-4.5 1-1.3 2.2-2.3 3.8-3C25 .4 26.8 0 28.8 0s3.8.4 5.3 1.1c1.5.7 2.7 1.7 3.6 3a7.2 7.2 0 0 1 1.2 4.2c0 1.6-.5 3-1.5 4a7 7 0 0 1-4 2.2v.2c2.2.3 3.8 1 5 2.2a6.4 6.4 0 0 1 1.6 4.6c0 1.7-.5 3.1-1.4 4.4a9.7 9.7 0 0 1-4 3.1c-1.7.8-3.7 1.1-5.8 1.1Z"/></g><defs><linearGradient id="a" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient><linearGradient id="b" x1="20" x2="20" y1="0" y2="30.1" gradientUnits="userSpaceOnUse"><stop/><stop offset="1" stop-color="#3D3D3D"/></linearGradient></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>
|
||||
|
Before Width: | Height: | Size: 629 B |
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@formbricks/config-typescript/nextjs.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
@@ -11,13 +11,12 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
@@ -27,14 +26,13 @@
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.1",
|
||||
"@typescript-eslint/parser": "8.29.1",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "8.31.1",
|
||||
"@typescript-eslint/parser": "8.31.1",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.5"
|
||||
"vite": "6.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@ module.exports = {
|
||||
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["lib/messages/**/*.json"],
|
||||
files: ["locales/*.json"],
|
||||
plugins: ["i18n-json"],
|
||||
rules: {
|
||||
"i18n-json/identical-keys": [
|
||||
"error",
|
||||
{
|
||||
filePath: require("path").join(__dirname, "messages", "en-US.json"),
|
||||
filePath: require("path").join(__dirname, "locales", "en-US.json"),
|
||||
checkExtraKeys: false,
|
||||
checkMissingKeys: true,
|
||||
},
|
||||
|
||||
@@ -79,7 +79,6 @@ RUN npm install -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
# && addgroup --system --gid 1001 nodejs \
|
||||
&& addgroup -S nextjs \
|
||||
&& adduser -S -u 1001 -G nextjs nextjs
|
||||
@@ -129,8 +128,7 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
COPY --from=installer /prisma_version.txt .
|
||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod -R 755 /app/docker/cronjobs
|
||||
|
||||
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
@@ -156,12 +154,6 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||
echo "Starting cron jobs..."; \
|
||||
supercronic -quiet /app/docker/cronjobs & \
|
||||
else \
|
||||
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
|
||||
fi; \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
CMD (cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
@@ -1,8 +1,13 @@
|
||||
import { getDefaultEndingCard } from "@/app/lib/templates";
|
||||
import {
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
buildOpenTextQuestion,
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} from "@/app/lib/survey-builder";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { TFnType } from "@tolgee/react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TXMTemplate } from "@formbricks/types/templates";
|
||||
|
||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
||||
@@ -26,35 +31,26 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.nps_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: { default: t("templates.nps_survey_question_1_headline") },
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.nps_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") },
|
||||
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.nps_survey_question_2_headline") },
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.nps_survey_question_3_headline") },
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.nps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -67,9 +63,8 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.star_rating_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -102,16 +97,15 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: t("templates.star_rating_survey_question_1_headline") },
|
||||
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: t("templates.star_rating_survey_question_2_html") },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: t("templates.star_rating_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -138,25 +132,23 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: t("templates.star_rating_survey_question_2_headline") },
|
||||
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") },
|
||||
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.star_rating_survey_question_3_headline") },
|
||||
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: { default: t("templates.star_rating_survey_question_3_subheader") },
|
||||
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") },
|
||||
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") },
|
||||
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -169,9 +161,8 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.csat_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -204,15 +195,14 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: t("templates.csat_survey_question_1_headline") },
|
||||
headline: t("templates.csat_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -239,25 +229,20 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: t("templates.csat_survey_question_2_headline") },
|
||||
headline: t("templates.csat_survey_question_2_headline"),
|
||||
required: false,
|
||||
placeholder: { default: t("templates.csat_survey_question_2_placeholder") },
|
||||
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.csat_survey_question_3_headline") },
|
||||
headline: t("templates.csat_survey_question_3_headline"),
|
||||
required: false,
|
||||
placeholder: { default: t("templates.csat_survey_question_3_placeholder") },
|
||||
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -267,28 +252,22 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.cess_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
buildRatingQuestion({
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: { default: t("templates.cess_survey_question_1_headline") },
|
||||
headline: t("templates.cess_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.cess_survey_question_2_headline") },
|
||||
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.cess_survey_question_2_headline"),
|
||||
required: true,
|
||||
placeholder: { default: t("templates.cess_survey_question_2_placeholder") },
|
||||
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -301,9 +280,8 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
...defaultSurvey,
|
||||
name: t("templates.smileys_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
buildRatingQuestion({
|
||||
id: reusableQuestionIds[0],
|
||||
type: TSurveyQuestionTypeEnum.Rating,
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -336,16 +314,15 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: { default: t("templates.smileys_survey_question_1_headline") },
|
||||
headline: t("templates.smileys_survey_question_1_headline"),
|
||||
required: true,
|
||||
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") },
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
{
|
||||
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||
t,
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: { default: t("templates.smileys_survey_question_2_html") },
|
||||
type: TSurveyQuestionTypeEnum.CTA,
|
||||
html: t("templates.smileys_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -372,25 +349,23 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
],
|
||||
},
|
||||
],
|
||||
headline: { default: t("templates.smileys_survey_question_2_headline") },
|
||||
headline: t("templates.smileys_survey_question_2_headline"),
|
||||
required: true,
|
||||
buttonUrl: "https://formbricks.com/github",
|
||||
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") },
|
||||
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
id: reusableQuestionIds[2],
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.smileys_survey_question_3_headline") },
|
||||
headline: t("templates.smileys_survey_question_3_headline"),
|
||||
required: true,
|
||||
subheader: { default: t("templates.smileys_survey_question_3_subheader") },
|
||||
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") },
|
||||
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") },
|
||||
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
@@ -400,37 +375,26 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
|
||||
...getXMSurveyDefault(t),
|
||||
name: t("templates.enps_survey_name"),
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.NPS,
|
||||
headline: {
|
||||
default: t("templates.enps_survey_question_1_headline"),
|
||||
},
|
||||
buildNPSQuestion({
|
||||
headline: t("templates.enps_survey_question_1_headline"),
|
||||
required: false,
|
||||
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") },
|
||||
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") },
|
||||
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||
isColorCodingEnabled: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.enps_survey_question_2_headline") },
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_2_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: t("templates.enps_survey_question_3_headline") },
|
||||
t,
|
||||
}),
|
||||
buildOpenTextQuestion({
|
||||
headline: t("templates.enps_survey_question_3_headline"),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
t,
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
@@ -125,7 +124,6 @@ export const LandingSidebar = ({
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await formbricksLogout();
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import formbricks from "@formbricks/js";
|
||||
import { FormbricksClient } from "./FormbricksClient";
|
||||
|
||||
// Mock next/navigation hooks.
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/test-path",
|
||||
useSearchParams: () => new URLSearchParams("foo=bar"),
|
||||
}));
|
||||
|
||||
// Mock the flag that enables Formbricks.
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
formbricksEnabled: true,
|
||||
}));
|
||||
|
||||
// Mock the Formbricks SDK module.
|
||||
vi.mock("@formbricks/js", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
setup: vi.fn(),
|
||||
setUserId: vi.fn(),
|
||||
setEmail: vi.fn(),
|
||||
registerRouteChange: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("FormbricksClient", () => {
|
||||
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
|
||||
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||
|
||||
render(
|
||||
<FormbricksClient
|
||||
userId="user-123"
|
||||
email="test@example.com"
|
||||
formbricksEnvironmentId="env-test"
|
||||
formbricksApiHost="https://api.test.com"
|
||||
formbricksEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Expect the first effect to call setup and assign the provided user details.
|
||||
expect(mockSetup).toHaveBeenCalledWith({
|
||||
environmentId: "env-test",
|
||||
appUrl: "https://api.test.com",
|
||||
});
|
||||
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
|
||||
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
|
||||
|
||||
// And the second effect should always register the route change when Formbricks is enabled.
|
||||
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
|
||||
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||
|
||||
render(
|
||||
<FormbricksClient
|
||||
userId=""
|
||||
email="test@example.com"
|
||||
formbricksEnvironmentId="env-test"
|
||||
formbricksApiHost="https://api.test.com"
|
||||
formbricksEnabled={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Since userId is falsy, the first effect should not call setup or assign user details.
|
||||
expect(mockSetup).not.toHaveBeenCalled();
|
||||
expect(mockSetUserId).not.toHaveBeenCalled();
|
||||
expect(mockSetEmail).not.toHaveBeenCalled();
|
||||
|
||||
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
|
||||
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import formbricks from "@formbricks/js";
|
||||
|
||||
interface FormbricksClientProps {
|
||||
userId: string;
|
||||
email: string;
|
||||
formbricksEnvironmentId?: string;
|
||||
formbricksApiHost?: string;
|
||||
formbricksEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const FormbricksClient = ({
|
||||
userId,
|
||||
email,
|
||||
formbricksEnvironmentId,
|
||||
formbricksApiHost,
|
||||
formbricksEnabled,
|
||||
}: FormbricksClientProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled && userId) {
|
||||
formbricks.setup({
|
||||
environmentId: formbricksEnvironmentId ?? "",
|
||||
appUrl: formbricksApiHost ?? "",
|
||||
});
|
||||
|
||||
formbricks.setUserId(userId);
|
||||
formbricks.setEmail(email);
|
||||
}
|
||||
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formbricksEnabled) {
|
||||
formbricks.registerRouteChange();
|
||||
}
|
||||
}, [pathname, searchParams, formbricksEnabled]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -265,7 +264,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -392,7 +391,6 @@ export const MainNavigation = ({
|
||||
onClick={async () => {
|
||||
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
|
||||
router.push(route.url);
|
||||
await formbricksLogout();
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -26,13 +26,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||
}));
|
||||
vi.mock("../../components/FormbricksClient", () => ({
|
||||
FormbricksClient: ({ userId, email }: any) => (
|
||||
<div data-testid="FormbricksClient">
|
||||
{userId}-{email}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
||||
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
@@ -37,7 +36,6 @@ export const DeleteAccount = ({
|
||||
setOpen={setModalOpen}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
formbricksLogout={formbricksLogout}
|
||||
organizationsWithSingleOwner={organizationsWithSingleOwner}
|
||||
/>
|
||||
<p className="text-sm text-slate-700">
|
||||
|
||||
@@ -58,11 +58,6 @@ const Page = async (props) => {
|
||||
comingSoon: false,
|
||||
onRequest: false,
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.enterprise.ai"),
|
||||
comingSoon: false,
|
||||
onRequest: true,
|
||||
},
|
||||
{
|
||||
title: t("environments.settings.enterprise.audit_logs"),
|
||||
comingSoon: false,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateOrganizationAIEnabledAction } from "@/modules/ee/insights/actions";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
|
||||
interface AIToggleProps {
|
||||
environmentId: string;
|
||||
organization: TOrganization;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const AIToggle = ({ organization, isOwnerOrManager }: AIToggleProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isAIEnabled, setIsAIEnabled] = useState(organization.isAIEnabled);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleUpdateOrganization = async (data) => {
|
||||
try {
|
||||
setIsAIEnabled(data.enabled);
|
||||
setIsSubmitting(true);
|
||||
const updatedOrganizationResponse = await updateOrganizationAIEnabledAction({
|
||||
organizationId: organization.id,
|
||||
data: {
|
||||
isAIEnabled: data.enabled,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedOrganizationResponse?.data) {
|
||||
if (data.enabled) {
|
||||
toast.success(t("environments.settings.general.formbricks_ai_enable_success_message"));
|
||||
} else {
|
||||
toast.success(t("environments.settings.general.formbricks_ai_disable_success_message"));
|
||||
}
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(updatedOrganizationResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(`Error: ${err.message}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (typeof window !== "undefined") {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="formbricks-ai-toggle" className="cursor-pointer">
|
||||
{t("environments.settings.general.enable_formbricks_ai")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="formbricks-ai-toggle"
|
||||
disabled={!isOwnerOrManager || isSubmitting}
|
||||
checked={isAIEnabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleUpdateOrganization({ enabled: !organization.isAIEnabled });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-slate-600">
|
||||
{t("environments.settings.general.formbricks_ai_privacy_policy_text")}{" "}
|
||||
<Link
|
||||
className="underline"
|
||||
href={"https://formbricks.com/privacy-policy"}
|
||||
rel="noreferrer"
|
||||
target="_blank">
|
||||
{t("common.privacy_policy")}
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
{!isOwnerOrManager && (
|
||||
<Alert variant="warning" className="mt-4">
|
||||
<AlertDescription>
|
||||
{t("environments.settings.general.only_org_owner_can_perform_action")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +1,5 @@
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -33,12 +29,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-ai-azure-llm-ressource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-ai",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-ai-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-ai-azure-embeddings-ressource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-ai-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-ai-azure-embeddings-deployment-id",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
@@ -59,7 +49,6 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getIsOrganizationAIReady: vi.fn(),
|
||||
getWhiteLabelPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -80,7 +69,6 @@ describe("Page", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue(mockEnvironmentAuth);
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
|
||||
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { AIToggle } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/components/AIToggle";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
@@ -35,8 +30,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
const isOrganizationAIReady = await getIsOrganizationAIReady(organization.billing.plan);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||
@@ -56,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</SettingsCard>
|
||||
{isOrganizationAIReady && (
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.formbricks_ai")}
|
||||
description={t("environments.settings.general.formbricks_ai_description")}>
|
||||
<AIToggle
|
||||
environmentId={params.environmentId}
|
||||
organization={organization}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
<EmailCustomizationSettings
|
||||
organization={organization}
|
||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { generateInsightsForSurvey } from "@/app/api/(internal)/insights/lib/utils";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
@@ -108,31 +107,3 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGenerateInsightsForSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const generateInsightsForSurveyAction = authenticatedActionClient
|
||||
.schema(ZGenerateInsightsForSurveyAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
schema: ZGenerateInsightsForSurveyAction,
|
||||
data: parsedInput,
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
generateInsightsForSurvey(parsedInput.surveyId);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { EnableInsightsBanner } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/EnableInsightsBanner";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { needsInsightsGeneration } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { MAX_RESPONSES_FOR_INSIGHT_GENERATION, RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { RESPONSES_PER_PAGE, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getSurveyDomain } from "@/lib/getSurveyUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getIsAIEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
@@ -20,7 +17,7 @@ const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
const t = await getTranslate();
|
||||
|
||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
const { session, environment, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
|
||||
@@ -38,11 +35,6 @@ const Page = async (props) => {
|
||||
|
||||
const totalResponseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
|
||||
const isAIEnabled = await getIsAIEnabled({
|
||||
isAIEnabled: organization.isAIEnabled,
|
||||
billing: organization.billing,
|
||||
});
|
||||
const shouldGenerateInsights = needsInsightsGeneration(survey);
|
||||
const locale = await findMatchingLocale();
|
||||
const surveyDomain = getSurveyDomain();
|
||||
|
||||
@@ -57,16 +49,9 @@ const Page = async (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
user={user}
|
||||
surveyDomain={surveyDomain}
|
||||
responseCount={totalResponseCount}
|
||||
/>
|
||||
}>
|
||||
{isAIEnabled && shouldGenerateInsights && (
|
||||
<EnableInsightsBanner
|
||||
surveyId={survey.id}
|
||||
surveyResponseCount={totalResponseCount}
|
||||
maxResponseCount={MAX_RESPONSES_FOR_INSIGHT_GENERATION}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SurveyAnalysisNavigation
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryAddress } from "@formbricks/types/surveys/types";
|
||||
import { AddressSummary } from "./AddressSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/array-response", () => ({
|
||||
ArrayResponse: ({ value }: { value: string[] }) => (
|
||||
<div data-testid="array-response">{value.join(", ")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("AddressSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact information correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["123 Main St", "Apt 4", "New York", "NY", "10001"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: { email: "user@example.com" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("123 Main St, Apt 4, New York, NY, 10001");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check link to contact
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response2",
|
||||
value: ["456 Oak St", "London", "UK"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("456 Oak St, London, UK");
|
||||
});
|
||||
|
||||
test("renders multiple responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Address Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["123 Main St", "New York"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: ["456 Oak St", "London"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryAddress;
|
||||
|
||||
render(
|
||||
<AddressSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
|
||||
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryCta } from "@formbricks/types/surveys/types";
|
||||
import { CTASummary } from "./CTASummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({
|
||||
additionalInfo,
|
||||
}: {
|
||||
showResponses: boolean;
|
||||
additionalInfo: React.ReactNode;
|
||||
}) => <div data-testid="question-summary-header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("CTASummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders with all metrics and required question", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: true },
|
||||
impressionCount: 100,
|
||||
clickCount: 25,
|
||||
skipCount: 10,
|
||||
ctr: { count: 25, percentage: 25 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("100 common.impressions")).toBeInTheDocument();
|
||||
// Use getAllByText instead of getByText for multiple matching elements
|
||||
expect(screen.getAllByText("25 common.clicks")).toHaveLength(2);
|
||||
expect(screen.queryByText("10 common.skips")).not.toBeInTheDocument(); // Should not show skips for required questions
|
||||
|
||||
// Check CTR section
|
||||
expect(screen.getByText("CTR")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00%")).toBeInTheDocument();
|
||||
|
||||
// Check progress bar
|
||||
expect(screen.getByTestId("progress-bar")).toHaveTextContent("0.25-bg-brand-dark");
|
||||
});
|
||||
|
||||
test("renders skip count for non-required questions", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: false },
|
||||
impressionCount: 100,
|
||||
clickCount: 20,
|
||||
skipCount: 30,
|
||||
ctr: { count: 20, percentage: 20 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByText("30 common.skips")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders singular form for count = 1", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "CTA Question", required: true },
|
||||
impressionCount: 10,
|
||||
clickCount: 1,
|
||||
skipCount: 0,
|
||||
ctr: { count: 1, percentage: 10 },
|
||||
} as unknown as TSurveyQuestionSummaryCta;
|
||||
|
||||
render(<CTASummary questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
// Use getAllByText instead of getByText for multiple matching elements
|
||||
expect(screen.getAllByText("1 common.click")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryCal } from "@formbricks/types/surveys/types";
|
||||
import { CalSummary } from "./CalSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("CalSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders the correct components and data", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Calendar Question" },
|
||||
booked: { count: 5, percentage: 75 },
|
||||
skipped: { count: 1, percentage: 25 },
|
||||
} as unknown as TSurveyQuestionSummaryCal;
|
||||
|
||||
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
|
||||
// Check if booked section is displayed
|
||||
expect(screen.getByText("common.booked")).toBeInTheDocument();
|
||||
expect(screen.getByText("75.00%")).toBeInTheDocument();
|
||||
expect(screen.getByText("5 common.responses")).toBeInTheDocument();
|
||||
|
||||
// Check if skipped section is displayed
|
||||
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText("25.00%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.response")).toBeInTheDocument();
|
||||
|
||||
// Check progress bars
|
||||
const progressBars = screen.getAllByTestId("progress-bar");
|
||||
expect(progressBars).toHaveLength(2);
|
||||
expect(progressBars[0]).toHaveTextContent("0.75-bg-brand-dark");
|
||||
expect(progressBars[1]).toHaveTextContent("0.25-bg-brand-dark");
|
||||
});
|
||||
|
||||
test("renders singular and plural response counts correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Calendar Question" },
|
||||
booked: { count: 1, percentage: 50 },
|
||||
skipped: { count: 1, percentage: 50 },
|
||||
} as unknown as TSurveyQuestionSummaryCal;
|
||||
|
||||
render(<CalSummary questionSummary={questionSummary} environmentId={environmentId} survey={survey} />);
|
||||
|
||||
// Use getAllByText directly since we know there are multiple matching elements
|
||||
const responseElements = screen.getAllByText("1 common.response");
|
||||
expect(responseElements).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryContactInfo } from "@formbricks/types/surveys/types";
|
||||
import { ContactInfoSummary } from "./ContactInfoSummary";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/array-response", () => ({
|
||||
ArrayResponse: ({ value }: { value: string[] }) => (
|
||||
<div data-testid="array-response">{value.join(", ")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("ContactInfoSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact information correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["John Doe", "john@example.com", "+1234567890"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: { email: "user@example.com" },
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("John Doe, john@example.com, +1234567890");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check link to contact
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/contact1`);
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response2",
|
||||
value: ["Anonymous User", "anonymous@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("array-response")).toHaveTextContent("Anonymous User, anonymous@example.com");
|
||||
});
|
||||
|
||||
test("renders multiple responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Contact Info Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: ["John Doe", "john@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: ["Jane Smith", "jane@example.com"],
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryContactInfo;
|
||||
|
||||
render(
|
||||
<ContactInfoSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByTestId("person-avatar")).toHaveLength(2);
|
||||
expect(screen.getAllByTestId("array-response")).toHaveLength(2);
|
||||
expect(screen.getAllByText("2 hours ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryDate } from "@formbricks/types/surveys/types";
|
||||
import { DateQuestionSummary } from "./DateQuestionSummary";
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
formatDateWithOrdinal: (_: Date) => "January 1st, 2023",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} data-testid="next-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("DateQuestionSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = {} as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders table headers correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders date responses correctly", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("January 1st, 2023")).toBeInTheDocument();
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders invalid dates with special message", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "invalid-date",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.invalid_date(invalid-date)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: "2023-01-01",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Date Question" },
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryDate;
|
||||
|
||||
render(
|
||||
<DateQuestionSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText("January 1st, 2023")).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { generateInsightsForSurveyAction } from "@/modules/ee/insights/actions";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface EnableInsightsBannerProps {
|
||||
surveyId: string;
|
||||
maxResponseCount: number;
|
||||
surveyResponseCount: number;
|
||||
}
|
||||
|
||||
export const EnableInsightsBanner = ({
|
||||
surveyId,
|
||||
surveyResponseCount,
|
||||
maxResponseCount,
|
||||
}: EnableInsightsBannerProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isGeneratingInsights, setIsGeneratingInsights] = useState(false);
|
||||
|
||||
const handleInsightGeneration = async () => {
|
||||
toast.success("Generating insights for this survey. Please check back in a few minutes.", {
|
||||
duration: 3000,
|
||||
});
|
||||
setIsGeneratingInsights(true);
|
||||
toast.success(t("environments.surveys.summary.enable_ai_insights_banner_success"));
|
||||
generateInsightsForSurveyAction({ surveyId });
|
||||
};
|
||||
|
||||
if (isGeneratingInsights) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="mb-6 mt-4 flex items-center gap-4 border-slate-400 bg-white">
|
||||
<div>
|
||||
<SparklesIcon strokeWidth={1.5} className="size-7 text-slate-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertTitle>
|
||||
<span className="mr-2">{t("environments.surveys.summary.enable_ai_insights_banner_title")}</span>
|
||||
<Badge type="gray" size="normal" text="Beta" />
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex items-start justify-between gap-4">
|
||||
{t("environments.surveys.summary.enable_ai_insights_banner_description")}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
<TooltipRenderer
|
||||
tooltipContent={
|
||||
surveyResponseCount > maxResponseCount
|
||||
? t("environments.surveys.summary.enable_ai_insights_banner_tooltip")
|
||||
: undefined
|
||||
}>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleInsightGeneration}
|
||||
loading={isGeneratingInsights}
|
||||
disabled={surveyResponseCount > maxResponseCount}>
|
||||
{t("environments.surveys.summary.enable_ai_insights_banner_button")}
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import { FileUploadSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/FileUploadSummary";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyQuestionSummaryFileUpload,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: vi.fn(() => <div>PersonAvatarMock</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: vi.fn(() => <div>QuestionSummaryHeaderMock</div>),
|
||||
}));
|
||||
|
||||
// Mock utility functions
|
||||
vi.mock("@/lib/storage/utils", () => ({
|
||||
getOriginalFileNameFromUrl: (url: string) => `original-${url.split("/").pop()}`,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "some time ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const survey = { id: "survey-1" } as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
const createMockResponse = (id: string, value: string[], contactId: string | null = null) => ({
|
||||
id: `response-${id}`,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: contactId ? { id: contactId, name: `Contact ${contactId}` } : null,
|
||||
contactAttributes: contactId ? { email: `contact${contactId}@example.com` } : {},
|
||||
});
|
||||
|
||||
const questionSummaryBase = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { default: "Upload your file" },
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
} as unknown as TSurveyFileUploadQuestion,
|
||||
responseCount: 0,
|
||||
files: [],
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
describe("FileUploadSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders the component with initial responses", () => {
|
||||
const files = Array.from({ length: 5 }, (_, i) =>
|
||||
createMockResponse(i.toString(), [`https://example.com/file${i}.pdf`], `contact-${i}`)
|
||||
);
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("QuestionSummaryHeaderMock")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(5);
|
||||
expect(screen.getAllByText("contact@example.com")).toHaveLength(5);
|
||||
expect(screen.getByText("original-file0.pdf")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-file4.pdf")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'Skipped' when value is an empty array", () => {
|
||||
const files = [createMockResponse("skipped", [], "contact-skipped")];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.skipped")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/original-/)).not.toBeInTheDocument(); // No file name should be rendered
|
||||
});
|
||||
|
||||
test("renders 'Anonymous' when contact is null", () => {
|
||||
const files = [createMockResponse("anon", ["https://example.com/anonfile.jpg"], null)];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-anonfile.jpg")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'Load More' button when there are more than 10 responses and loads more on click", async () => {
|
||||
const files = Array.from({ length: 15 }, (_, i) =>
|
||||
createMockResponse(i.toString(), [`https://example.com/file${i}.txt`], `contact-${i}`)
|
||||
);
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(10);
|
||||
expect(screen.getByText("original-file9.txt")).toBeInTheDocument();
|
||||
expect(screen.queryByText("original-file10.txt")).not.toBeInTheDocument();
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByText("common.load_more");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText("PersonAvatarMock")).toHaveLength(15);
|
||||
expect(screen.getByText("original-file14.txt")).toBeInTheDocument();
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders multiple files for a single response", () => {
|
||||
const files = [
|
||||
createMockResponse(
|
||||
"multi",
|
||||
["https://example.com/fileA.png", "https://example.com/fileB.docx"],
|
||||
"contact-multi"
|
||||
),
|
||||
];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("original-fileA.png")).toBeInTheDocument();
|
||||
expect(screen.getByText("original-fileB.docx")).toBeInTheDocument();
|
||||
// Check that download links exist
|
||||
const links = screen.getAllByRole("link");
|
||||
// 1 contact link + 2 file links
|
||||
expect(links.filter((link) => link.getAttribute("target") === "_blank")).toHaveLength(2);
|
||||
expect(
|
||||
links.find((link) => link.getAttribute("href") === "https://example.com/fileA.png")
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
links.find((link) => link.getAttribute("href") === "https://example.com/fileB.docx")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders contact link correctly", () => {
|
||||
const contactId = "contact-link-test";
|
||||
const files = [createMockResponse("link", ["https://example.com/link.pdf"], contactId)];
|
||||
const questionSummary = {
|
||||
...questionSummaryBase,
|
||||
files,
|
||||
responseCount: files.length,
|
||||
} as unknown as TSurveyQuestionSummaryFileUpload;
|
||||
|
||||
render(
|
||||
<FileUploadSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
const contactLink = screen.getByText("contact@example.com").closest("a");
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
expect(contactLink).toHaveAttribute("href", `/environments/${environmentId}/contacts/${contactId}`);
|
||||
});
|
||||
});
|
||||
@@ -74,12 +74,12 @@ export const FileUploadSummary = ({
|
||||
<div className="col-span-2 grid">
|
||||
{Array.isArray(response.value) &&
|
||||
(response.value.length > 0 ? (
|
||||
response.value.map((fileUrl, index) => {
|
||||
response.value.map((fileUrl) => {
|
||||
const fileName = getOriginalFileNameFromUrl(fileUrl);
|
||||
|
||||
return (
|
||||
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
|
||||
<a href={fileUrl} key={index} target="_blank" rel="noopener noreferrer">
|
||||
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
|
||||
<div className="absolute top-0 right-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" />
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurveyQuestionSummaryHiddenFields } from "@formbricks/types/surveys/types";
|
||||
import { HiddenFieldsSummary } from "./HiddenFieldsSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react components
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon" />,
|
||||
MessageSquareTextIcon: () => <div data-testid="message-icon" />,
|
||||
Link: ({ children, href, className }: { children: React.ReactNode; href: string; className: string }) => (
|
||||
<a href={href} className={className} data-testid="lucide-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Next.js Link
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href} data-testid="next-link">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("HiddenFieldsSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environment = { id: "env-123" } as TEnvironment;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders component with correct header and single response", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 1,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Hidden value",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("hidden-field-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hidden Field")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.response")).toBeInTheDocument();
|
||||
|
||||
// Headers
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.response")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.time")).toBeInTheDocument();
|
||||
|
||||
// We can skip checking for PersonAvatar as it's inside hidden md:flex
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hidden value")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// Check for link without checking for specific href
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 1,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Anonymous hidden value",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
// Instead of checking for avatar, just check for anonymous text
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
expect(screen.getByText("Anonymous hidden value")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders plural response label when multiple responses", () => {
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: 2,
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Hidden value 1",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
{
|
||||
id: "response2",
|
||||
value: "Hidden value 2",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact2" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
expect(screen.getByText("2 common.responses")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("contact@example.com")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: `Hidden value ${i}`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
id: "hidden-field-1",
|
||||
responseCount: samples.length,
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryHiddenFields;
|
||||
|
||||
render(
|
||||
<HiddenFieldsSummary environment={environment} questionSummary={questionSummary} locale={locale} />
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByText(/Hidden value \d+/)).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryOpenText } from "@formbricks/types/surveys/types";
|
||||
import { OpenTextSummary } from "./OpenTextSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: () => "2 hours ago",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/contact", () => ({
|
||||
getContactIdentifier: () => "contact@example.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/analysis/utils", () => ({
|
||||
renderHyperlinkedContent: (text: string) => <div data-testid="hyperlinked-content">{text}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: { personId: string }) => <div data-testid="person-avatar">{personId}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick: () => void }) => (
|
||||
<button onClick={onClick} data-testid="load-more-button">
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||
SecondaryNavigation: ({ activeId, navigation }: any) => (
|
||||
<div data-testid="secondary-navigation">
|
||||
{navigation.map((item: any) => (
|
||||
<button key={item.id} onClick={item.onClick} data-active={activeId === item.id}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/table", () => ({
|
||||
Table: ({ children }: { children: React.ReactNode }) => <table data-testid="table">{children}</table>,
|
||||
TableHeader: ({ children }: { children: React.ReactNode }) => <thead>{children}</thead>,
|
||||
TableBody: ({ children }: { children: React.ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableRow: ({ children }: { children: React.ReactNode }) => <tr>{children}</tr>,
|
||||
TableHead: ({ children }: { children: React.ReactNode }) => <th>{children}</th>,
|
||||
TableCell: ({ children, width }: { children: React.ReactNode; width?: number }) => (
|
||||
<td style={width ? { width } : {}}>{children}</td>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/insights/components/insights-view", () => ({
|
||||
InsightView: () => <div data-testid="insight-view"></div>,
|
||||
}));
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: { additionalInfo?: React.ReactNode }) => (
|
||||
<div data-testid="question-summary-header">{additionalInfo}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("OpenTextSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const environmentId = "env-123";
|
||||
const survey = { id: "survey-1" } as TSurvey;
|
||||
const locale = "en-US";
|
||||
|
||||
test("renders response mode by default when insights not enabled", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Sample response text",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: { id: "contact1" },
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("contact1");
|
||||
expect(screen.getByText("contact@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("hyperlinked-content")).toHaveTextContent("Sample response text");
|
||||
expect(screen.getByText("2 hours ago")).toBeInTheDocument();
|
||||
|
||||
// No secondary navigation when insights not enabled
|
||||
expect(screen.queryByTestId("secondary-navigation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders anonymous user when no contact is provided", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples: [
|
||||
{
|
||||
id: "response1",
|
||||
value: "Anonymous response",
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
},
|
||||
],
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("person-avatar")).toHaveTextContent("anonymous");
|
||||
expect(screen.getByText("common.anonymous")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows load more button when there are more responses and loads more on click", async () => {
|
||||
const samples = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `response${i}`,
|
||||
value: `Response ${i}`,
|
||||
updatedAt: new Date().toISOString(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
}));
|
||||
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Open Text Question" },
|
||||
samples,
|
||||
} as unknown as TSurveyQuestionSummaryOpenText;
|
||||
|
||||
render(
|
||||
<OpenTextSummary
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environmentId}
|
||||
survey={survey}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially 10 responses should be visible
|
||||
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(10);
|
||||
|
||||
// "Load More" button should be visible
|
||||
const loadMoreButton = screen.getByTestId("load-more-button");
|
||||
expect(loadMoreButton).toBeInTheDocument();
|
||||
|
||||
// Click "Load More"
|
||||
await userEvent.click(loadMoreButton);
|
||||
|
||||
// Now all 15 responses should be visible
|
||||
expect(screen.getAllByTestId("hyperlinked-content")).toHaveLength(15);
|
||||
|
||||
// "Load More" button should disappear
|
||||
expect(screen.queryByTestId("load-more-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,8 @@
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
|
||||
import { InsightView } from "@/modules/ee/insights/components/insights-view";
|
||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Link from "next/link";
|
||||
@@ -19,25 +17,12 @@ interface OpenTextSummaryProps {
|
||||
questionSummary: TSurveyQuestionSummaryOpenText;
|
||||
environmentId: string;
|
||||
survey: TSurvey;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
export const OpenTextSummary = ({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
survey,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: OpenTextSummaryProps) => {
|
||||
export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale }: OpenTextSummaryProps) => {
|
||||
const { t } = useTranslate();
|
||||
const isInsightsEnabled = isAIEnabled && questionSummary.insightsEnabled;
|
||||
const [visibleResponses, setVisibleResponses] = useState(10);
|
||||
const [activeTab, setActiveTab] = useState<"insights" | "responses">(
|
||||
isInsightsEnabled && questionSummary.insights.length ? "insights" : "responses"
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
// Increase the number of visible responses by 10, not exceeding the total number of responses
|
||||
@@ -46,104 +31,62 @@ export const OpenTextSummary = ({
|
||||
);
|
||||
};
|
||||
|
||||
const tabNavigation = [
|
||||
{
|
||||
id: "insights",
|
||||
label: t("common.insights"),
|
||||
onClick: () => setActiveTab("insights"),
|
||||
},
|
||||
{
|
||||
id: "responses",
|
||||
label: t("common.responses"),
|
||||
onClick: () => setActiveTab("responses"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
isAIEnabled && questionSummary.insightsEnabled === false ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
{t("environments.surveys.summary.insights_disabled")}
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{isInsightsEnabled && (
|
||||
<div className="ml-4">
|
||||
<SecondaryNavigation activeId={activeTab} navigation={tabNavigation} />
|
||||
</div>
|
||||
)}
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="border-t border-slate-200"></div>
|
||||
<div className="max-h-[40vh] overflow-y-auto">
|
||||
{activeTab === "insights" ? (
|
||||
<InsightView
|
||||
insights={questionSummary.insights}
|
||||
questionId={questionSummary.question.id}
|
||||
surveyId={survey.id}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
) : activeTab === "responses" ? (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableHeader className="bg-slate-100">
|
||||
<TableRow>
|
||||
<TableHead>{t("common.user")}</TableHead>
|
||||
<TableHead>{t("common.response")}</TableHead>
|
||||
<TableHead>{t("common.time")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||
<TableRow key={response.id}>
|
||||
<TableCell>
|
||||
{response.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.contact.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{getContactIdentifier(response.contact, response.contactAttributes)}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{typeof response.value === "string"
|
||||
? renderHyperlinkedContent(response.value)
|
||||
: response.value}
|
||||
</TableCell>
|
||||
<TableCell width={120}>
|
||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{visibleResponses < questionSummary.samples.length && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button onClick={handleLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummary, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: () => ({ default: "Recalled Headline" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
formatTextWithSlashes: (text: string) => <span data-testid="formatted-headline">{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionTypes: () => [
|
||||
{
|
||||
id: "openText",
|
||||
label: "Open Text",
|
||||
icon: () => <div data-testid="question-icon">Icon</div>,
|
||||
},
|
||||
{
|
||||
id: "multipleChoice",
|
||||
label: "Multiple Choice",
|
||||
icon: () => <div data-testid="question-icon">Icon</div>,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||
SettingsId: ({ title, id }: { title: string; id: string }) => (
|
||||
<div data-testid="settings-id">
|
||||
{title}: {id}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock InboxIcon
|
||||
vi.mock("lucide-react", () => ({
|
||||
InboxIcon: () => <div data-testid="inbox-icon"></div>,
|
||||
}));
|
||||
|
||||
describe("QuestionSummaryHeader", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders header with question headline and type", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { default: "Test Question" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 42,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByTestId("formatted-headline")).toHaveTextContent("Recalled Headline");
|
||||
|
||||
// Look for text content with a more specific approach
|
||||
const questionTypeElement = screen.getByText((content) => {
|
||||
return content.includes("Open Text") && !content.includes("common.question_id");
|
||||
});
|
||||
expect(questionTypeElement).toBeInTheDocument();
|
||||
|
||||
// Check for responses text specifically
|
||||
expect(
|
||||
screen.getByText((content) => {
|
||||
return content.includes("42") && content.includes("common.responses");
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId("question-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-id")).toHaveTextContent("common.question_id: q1");
|
||||
expect(screen.queryByText("environments.surveys.edit.optional")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'optional' tag when question is not required", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q2",
|
||||
headline: { default: "Optional Question" },
|
||||
type: "multipleChoice" as TSurveyQuestionTypeEnum,
|
||||
required: false,
|
||||
},
|
||||
responseCount: 10,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.edit.optional")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides response count when showResponses is false", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q3",
|
||||
headline: { default: "No Response Count Question" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 15,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} showResponses={false} />);
|
||||
|
||||
expect(
|
||||
screen.queryByText((content) => content.includes("15") && content.includes("common.responses"))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows unknown question type for unrecognized type", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q4",
|
||||
headline: { default: "Unknown Type Question" },
|
||||
type: "unknownType" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 5,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
render(<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />);
|
||||
|
||||
// Look for text in the question type element specifically
|
||||
const unknownTypeElement = screen.getByText((content) => {
|
||||
return (
|
||||
content.includes("environments.surveys.summary.unknown_question_type") &&
|
||||
!content.includes("common.question_id")
|
||||
);
|
||||
});
|
||||
expect(unknownTypeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders additional info when provided", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q5",
|
||||
headline: { default: "With Additional Info" },
|
||||
type: "openText" as TSurveyQuestionTypeEnum,
|
||||
required: true,
|
||||
},
|
||||
responseCount: 20,
|
||||
} as unknown as TSurveyQuestionSummary;
|
||||
|
||||
const additionalInfo = <div data-testid="additional-info">Extra Information</div>;
|
||||
|
||||
render(
|
||||
<QuestionSummaryHeader
|
||||
questionSummary={questionSummary}
|
||||
survey={survey}
|
||||
additionalInfo={additionalInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("additional-info")).toBeInTheDocument();
|
||||
expect(screen.getByText("Extra Information")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionSummaryRanking, TSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { RankingSummary } from "./RankingSummary";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
vi.mock("../lib/utils", () => ({
|
||||
convertFloatToNDecimal: (value: number) => value.toFixed(2),
|
||||
}));
|
||||
|
||||
describe("RankingSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
const surveyType: TSurveyType = "app";
|
||||
|
||||
test("renders ranking results in correct order", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: { value: "Option A", avgRanking: 1.5, others: [] },
|
||||
option2: { value: "Option B", avgRanking: 2.3, others: [] },
|
||||
option3: { value: "Option C", avgRanking: 1.2, others: [] },
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
|
||||
|
||||
expect(screen.getByTestId("question-summary-header")).toBeInTheDocument();
|
||||
|
||||
// Check order: should be sorted by avgRanking (ascending)
|
||||
const options = screen.getAllByText(/Option [A-C]/);
|
||||
expect(options[0]).toHaveTextContent("Option C"); // 1.2 (lowest avgRanking first)
|
||||
expect(options[1]).toHaveTextContent("Option A"); // 1.5
|
||||
expect(options[2]).toHaveTextContent("Option B"); // 2.3
|
||||
|
||||
// Check rankings are displayed
|
||||
expect(screen.getByText("#1")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2")).toBeInTheDocument();
|
||||
expect(screen.getByText("#3")).toBeInTheDocument();
|
||||
|
||||
// Check average values are displayed
|
||||
expect(screen.getByText("#1.20")).toBeInTheDocument();
|
||||
expect(screen.getByText("#1.50")).toBeInTheDocument();
|
||||
expect(screen.getByText("#2.30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders 'other values found' section when others exist", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 2 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType={surveyType} />);
|
||||
|
||||
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows 'User' column in other values section for app survey type", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 1 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="app" />);
|
||||
|
||||
expect(screen.getByText("common.user")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show 'User' column for link survey type", () => {
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Rank the following" },
|
||||
choices: {
|
||||
option1: {
|
||||
value: "Option A",
|
||||
avgRanking: 1.0,
|
||||
others: [{ value: "Other value", count: 1 }],
|
||||
},
|
||||
},
|
||||
} as unknown as TSurveyQuestionSummaryRanking;
|
||||
|
||||
render(<RankingSummary questionSummary={questionSummary} survey={survey} surveyType="link" />);
|
||||
|
||||
expect(screen.queryByText("common.user")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { SummaryDropOffs } from "./SummaryDropOffs";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: () => ({ default: "Recalled Question" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/utils", () => ({
|
||||
formatTextWithSlashes: (text) => <span data-testid="formatted-text">{text}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/questions", () => ({
|
||||
getQuestionIcon: () => () => <div data-testid="question-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-trigger">{children}</div>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
TimerIcon: () => <div data-testid="timer-icon" />,
|
||||
}));
|
||||
|
||||
describe("SummaryDropOffs", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSurvey = {} as TSurvey;
|
||||
const mockDropOff: TSurveySummary["dropOff"] = [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "First Question",
|
||||
questionType: TSurveyQuestionTypeEnum.OpenText,
|
||||
ttc: 15000, // 15 seconds
|
||||
impressions: 100,
|
||||
dropOffCount: 20,
|
||||
dropOffPercentage: 20,
|
||||
},
|
||||
{
|
||||
questionId: "q2",
|
||||
headline: "Second Question",
|
||||
questionType: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
|
||||
ttc: 30000, // 30 seconds
|
||||
impressions: 80,
|
||||
dropOffCount: 15,
|
||||
dropOffPercentage: 18.75,
|
||||
},
|
||||
{
|
||||
questionId: "q3",
|
||||
headline: "Third Question",
|
||||
questionType: TSurveyQuestionTypeEnum.Rating,
|
||||
ttc: 0, // No time data
|
||||
impressions: 65,
|
||||
dropOffCount: 10,
|
||||
dropOffPercentage: 15.38,
|
||||
},
|
||||
];
|
||||
|
||||
test("renders header row with correct columns", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
// Check header
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("timer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.drop_offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders tooltip with correct content", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.surveys.summary.ttc_tooltip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all drop-off items with correct data", () => {
|
||||
render(<SummaryDropOffs dropOff={mockDropOff} survey={mockSurvey} />);
|
||||
|
||||
// There should be 3 rows of data (one for each question)
|
||||
expect(screen.getAllByTestId("question-icon")).toHaveLength(3);
|
||||
expect(screen.getAllByTestId("formatted-text")).toHaveLength(3);
|
||||
|
||||
// Check time to complete values
|
||||
expect(screen.getByText("15.00s")).toBeInTheDocument(); // 15000ms converted to seconds
|
||||
expect(screen.getByText("30.00s")).toBeInTheDocument(); // 30000ms converted to seconds
|
||||
expect(screen.getByText("N/A")).toBeInTheDocument(); // 0ms shown as N/A
|
||||
|
||||
// Check impressions values
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("80")).toBeInTheDocument();
|
||||
expect(screen.getByText("65")).toBeInTheDocument();
|
||||
|
||||
// Check drop-off counts and percentages
|
||||
expect(screen.getByText("20")).toBeInTheDocument();
|
||||
expect(screen.getByText("(20%)")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("15")).toBeInTheDocument();
|
||||
expect(screen.getByText("(19%)")).toBeInTheDocument(); // 18.75% rounded to 19%
|
||||
|
||||
expect(screen.getByText("10")).toBeInTheDocument();
|
||||
expect(screen.getByText("(15%)")).toBeInTheDocument(); // 15.38% rounded to 15%
|
||||
});
|
||||
|
||||
test("renders empty state when dropOff array is empty", () => {
|
||||
render(<SummaryDropOffs dropOff={[]} survey={mockSurvey} />);
|
||||
|
||||
// Header should still be visible
|
||||
expect(screen.getByText("common.questions")).toBeInTheDocument();
|
||||
|
||||
// But no question icons
|
||||
expect(screen.queryByTestId("question-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,8 +39,6 @@ interface SummaryListProps {
|
||||
environment: TEnvironment;
|
||||
survey: TSurvey;
|
||||
totalResponseCount: number;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
@@ -50,8 +48,6 @@ export const SummaryList = ({
|
||||
responseCount,
|
||||
survey,
|
||||
totalResponseCount,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
}: SummaryListProps) => {
|
||||
const { setSelectedFilter, selectedFilter } = useResponseFilter();
|
||||
@@ -134,8 +130,6 @@ export const SummaryList = ({
|
||||
questionSummary={questionSummary}
|
||||
environmentId={environment.id}
|
||||
survey={survey}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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 { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { SummaryPage } from "./SummaryPage";
|
||||
|
||||
// Mock actions
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions", () => ({
|
||||
getResponseCountAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSurveySummaryAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
||||
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
|
||||
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
meta: {
|
||||
completedPercentage: 80,
|
||||
completedResponses: 40,
|
||||
displayCount: 50,
|
||||
dropOffPercentage: 20,
|
||||
dropOffCount: 10,
|
||||
startsPercentage: 100,
|
||||
totalResponses: 50,
|
||||
ttcAverage: 120,
|
||||
},
|
||||
dropOff: [
|
||||
{
|
||||
questionId: "q1",
|
||||
headline: "Question 1",
|
||||
questionType: "openText",
|
||||
ttc: 20000,
|
||||
impressions: 50,
|
||||
dropOffCount: 5,
|
||||
dropOffPercentage: 10,
|
||||
},
|
||||
],
|
||||
summary: [
|
||||
{
|
||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
||||
responseCount: 45,
|
||||
type: "openText",
|
||||
samples: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
||||
() => ({
|
||||
SummaryDropOffs: () => <div data-testid="summary-drop-offs">DropOffs Component</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList",
|
||||
() => ({
|
||||
SummaryList: ({ summary, responseCount }: any) => (
|
||||
<div data-testid="summary-list">
|
||||
<span>Response Count: {responseCount}</span>
|
||||
<span>Summary Items: {summary.length}</span>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
|
||||
() => ({
|
||||
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
|
||||
<div data-testid="summary-metadata">
|
||||
<span>Is Loading: {isLoading ? "true" : "false"}</span>
|
||||
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop",
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="scroll-to-top">Scroll To Top</div>,
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter", () => ({
|
||||
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
||||
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
|
||||
}));
|
||||
|
||||
// Mock context
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||
useResponseFilter: () => ({
|
||||
selectedFilter: { filter: [], onlyComplete: false },
|
||||
dateRange: { from: null, to: null },
|
||||
resetState: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
vi.mock("@/lib/utils/hooks/useIntervalWhenFocused", () => ({
|
||||
useIntervalWhenFocused: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
replaceHeadlineRecall: (survey: any) => survey,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({}),
|
||||
useSearchParams: () => ({ get: () => null }),
|
||||
}));
|
||||
|
||||
describe("SummaryPage", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockEnvironment = { id: "env-123" } as TEnvironment;
|
||||
const mockSurvey = {
|
||||
id: "survey-123",
|
||||
environmentId: "env-123",
|
||||
} as TSurvey;
|
||||
const locale = "en-US" as TUserLocale;
|
||||
|
||||
const defaultProps = {
|
||||
environment: mockEnvironment,
|
||||
survey: mockSurvey,
|
||||
surveyId: "survey-123",
|
||||
webAppUrl: "https://app.example.com",
|
||||
totalResponseCount: 50,
|
||||
locale,
|
||||
isReadOnly: false,
|
||||
};
|
||||
|
||||
test("renders loading state initially", () => {
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("summary-metadata")).toBeInTheDocument();
|
||||
expect(screen.getByText("Is Loading: true")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders summary components after loading", async () => {
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows drop-offs component when toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SummaryPage {...defaultProps} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Drop-offs should initially be hidden
|
||||
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
|
||||
|
||||
// Toggle drop-offs
|
||||
await user.click(screen.getByText("Toggle Dropoffs"));
|
||||
|
||||
// Drop-offs should now be visible
|
||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("doesn't show share button in read-only mode", async () => {
|
||||
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
|
||||
|
||||
// Wait for loading to complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,6 @@ interface SummaryPageProps {
|
||||
webAppUrl: string;
|
||||
user?: TUser;
|
||||
totalResponseCount: number;
|
||||
isAIEnabled: boolean;
|
||||
documentsPerPage?: number;
|
||||
locale: TUserLocale;
|
||||
isReadOnly: boolean;
|
||||
@@ -58,8 +57,6 @@ export const SummaryPage = ({
|
||||
surveyId,
|
||||
webAppUrl,
|
||||
totalResponseCount,
|
||||
isAIEnabled,
|
||||
documentsPerPage,
|
||||
locale,
|
||||
isReadOnly,
|
||||
}: SummaryPageProps) => {
|
||||
@@ -184,8 +181,6 @@ export const SummaryPage = ({
|
||||
survey={surveyMemoized}
|
||||
environment={environment}
|
||||
totalResponseCount={totalResponseCount}
|
||||
isAIEnabled={isAIEnabled}
|
||||
documentsPerPage={documentsPerPage}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyLink } from "@/modules/survey/lib/client-utils";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -22,6 +25,7 @@ interface SurveyAnalysisCTAProps {
|
||||
isReadOnly: boolean;
|
||||
user: TUser;
|
||||
surveyDomain: string;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
@@ -37,11 +41,13 @@ export const SurveyAnalysisCTA = ({
|
||||
isReadOnly,
|
||||
user,
|
||||
surveyDomain,
|
||||
responseCount,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
share: searchParams.get("share") === "true",
|
||||
@@ -89,6 +95,24 @@ export const SurveyAnalysisCTA = ({
|
||||
setModalState((prev) => ({ ...prev, dropdown: false }));
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRoute = async (surveyId: string) => {
|
||||
setLoading(true);
|
||||
const duplicatedSurveyResponse = await copySurveyToOtherEnvironmentAction({
|
||||
environmentId: environment.id,
|
||||
surveyId: surveyId,
|
||||
targetEnvironmentId: environment.id,
|
||||
});
|
||||
if (duplicatedSurveyResponse?.data) {
|
||||
toast.success(t("environments.surveys.survey_duplicated_successfully"));
|
||||
router.push(`/environments/${environment.id}/surveys/${duplicatedSurveyResponse.data.id}/edit`);
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(duplicatedSurveyResponse);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsCautionDialogOpen(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
@@ -107,6 +131,8 @@ export const SurveyAnalysisCTA = ({
|
||||
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
|
||||
];
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: Eye,
|
||||
@@ -144,7 +170,11 @@ export const SurveyAnalysisCTA = ({
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
onClick: () => router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`),
|
||||
onClick: () => {
|
||||
responseCount && responseCount > 0
|
||||
? setIsCautionDialogOpen(true)
|
||||
: router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`);
|
||||
},
|
||||
isVisible: !isReadOnly,
|
||||
},
|
||||
];
|
||||
@@ -182,6 +212,20 @@ export const SurveyAnalysisCTA = ({
|
||||
<SuccessMessage environment={environment} survey={survey} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{responseCount > 0 && (
|
||||
<EditPublicSurveyAlertDialog
|
||||
open={isCautionDialogOpen}
|
||||
setOpen={setIsCautionDialogOpen}
|
||||
isLoading={loading}
|
||||
primaryButtonAction={() => duplicateSurveyAndRoute(survey.id)}
|
||||
primaryButtonText={t("environments.surveys.edit.caution_edit_duplicate")}
|
||||
secondaryButtonAction={() =>
|
||||
router.push(`/environments/${environment.id}/surveys/${survey.id}/edit`)
|
||||
}
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
@@ -25,12 +25,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
AI_AZURE_LLM_RESSOURCE_NAME: "mock-azure-llm-resource-name",
|
||||
AI_AZURE_LLM_API_KEY: "mock-azure-llm-api-key",
|
||||
AI_AZURE_LLM_DEPLOYMENT_ID: "mock-azure-llm-deployment-id",
|
||||
AI_AZURE_EMBEDDINGS_RESSOURCE_NAME: "mock-azure-embeddings-resource-name",
|
||||
AI_AZURE_EMBEDDINGS_API_KEY: "mock-azure-embeddings-api-key",
|
||||
AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID: "mock-azure-embeddings-deployment-id",
|
||||
IS_PRODUCTION: true,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
@@ -49,10 +43,12 @@ vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
}));
|
||||
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
const mockPush = vi.fn();
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useSearchParams: () => mockSearchParams, // Reuse the same object
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
useSearchParams: () => mockSearchParams,
|
||||
usePathname: () => "/current",
|
||||
}));
|
||||
|
||||
@@ -61,13 +57,27 @@ vi.mock("@/modules/survey/lib/client-utils", () => ({
|
||||
copySurveyLink: vi.fn((url: string, id: string) => `${url}?id=${id}`),
|
||||
}));
|
||||
|
||||
// Mock the copy survey action
|
||||
const mockCopySurveyToOtherEnvironmentAction = vi.fn();
|
||||
vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: (args: any) => mockCopySurveyToOtherEnvironmentAction(args),
|
||||
}));
|
||||
|
||||
// Mock getFormattedErrorMessage function
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getFormattedErrorMessage: vi.fn((response) => response?.error || "Unknown error"),
|
||||
}));
|
||||
|
||||
vi.spyOn(toast, "success");
|
||||
vi.spyOn(toast, "error");
|
||||
|
||||
// Set up a fake clipboard
|
||||
const writeTextMock = vi.fn(() => Promise.resolve());
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: writeTextMock },
|
||||
// Mock clipboard API
|
||||
const writeTextMock = vi.fn().mockImplementation(() => Promise.resolve());
|
||||
|
||||
// Define it at the global level
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: writeTextMock },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const dummySurvey = {
|
||||
@@ -93,6 +103,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -117,6 +128,7 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -130,3 +142,225 @@ describe("SurveyAnalysisCTA - handleCopyLink", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// New tests for squarePenIcon and edit functionality
|
||||
describe("SurveyAnalysisCTA - Edit functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("opens EditPublicSurveyAlertDialog when edit icon is clicked and response count > 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Check if dialog is shown
|
||||
const dialogTitle = screen.getByText("environments.surveys.edit.caution_edit_published_survey");
|
||||
expect(dialogTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("navigates directly to edit page when response count = 0", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the edit button
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Should navigate directly to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("doesn't show edit button when isReadOnly is true", () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={true}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to find the edit button (it shouldn't exist)
|
||||
const editButton = screen.queryByRole("button", { name: "common.edit" });
|
||||
expect(editButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// Updated test description to mention EditPublicSurveyAlertDialog
|
||||
describe("SurveyAnalysisCTA - duplicateSurveyAndRoute and EditPublicSurveyAlertDialog", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("duplicates survey successfully and navigates to edit page", async () => {
|
||||
// Mock the API response
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find and click the edit button to show dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Find and click the duplicate button in dialog
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
expect(mockCopySurveyToOtherEnvironmentAction).toHaveBeenCalledWith({
|
||||
environmentId: dummyEnvironment.id,
|
||||
surveyId: dummySurvey.id,
|
||||
targetEnvironmentId: dummyEnvironment.id,
|
||||
});
|
||||
|
||||
// Verify success toast was shown
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.surveys.survey_duplicated_successfully");
|
||||
|
||||
// Verify navigation to edit page
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/duplicated-survey-456/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows error toast when duplication fails with error object", async () => {
|
||||
// Mock API failure with error object
|
||||
mockCopySurveyToOtherEnvironmentAction.mockResolvedValueOnce({
|
||||
error: "Test error message",
|
||||
});
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Verify error toast
|
||||
expect(toast.error).toHaveBeenCalledWith("Test error message");
|
||||
});
|
||||
|
||||
test("navigates to edit page when cancel button is clicked in dialog", async () => {
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click edit (cancel) button
|
||||
const editButtonInDialog = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButtonInDialog);
|
||||
|
||||
// Verify navigation
|
||||
expect(mockPush).toHaveBeenCalledWith(
|
||||
`/environments/${dummyEnvironment.id}/surveys/${dummySurvey.id}/edit`
|
||||
);
|
||||
});
|
||||
|
||||
test("shows loading state when duplicating survey", async () => {
|
||||
// Create a promise that we can resolve manually
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
mockCopySurveyToOtherEnvironmentAction.mockImplementation(() => promise);
|
||||
|
||||
render(
|
||||
<SurveyAnalysisCTA
|
||||
survey={dummySurvey}
|
||||
environment={dummyEnvironment}
|
||||
isReadOnly={false}
|
||||
surveyDomain={surveyDomain}
|
||||
user={dummyUser}
|
||||
responseCount={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open dialog
|
||||
const editButton = screen.getByRole("button", { name: "common.edit" });
|
||||
await fireEvent.click(editButton);
|
||||
|
||||
// Click duplicate
|
||||
const duplicateButton = screen.getByRole("button", {
|
||||
name: "environments.surveys.edit.caution_edit_duplicate",
|
||||
});
|
||||
await fireEvent.click(duplicateButton);
|
||||
|
||||
// Button should now be in loading state
|
||||
// expect(duplicateButton).toHaveAttribute("data-state", "loading");
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({
|
||||
data: { id: "duplicated-survey-456" },
|
||||
});
|
||||
|
||||
// Wait for the promise to resolve
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { cache } from "@/lib/cache";
|
||||
import { documentCache } from "@/lib/cache/document";
|
||||
import { INSIGHTS_PER_PAGE } from "@/lib/constants";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import {
|
||||
TSurveyQuestionId,
|
||||
TSurveyQuestionSummaryOpenText,
|
||||
ZSurveyQuestionId,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getInsightsBySurveyIdQuestionId = reactCache(
|
||||
async (
|
||||
surveyId: string,
|
||||
questionId: TSurveyQuestionId,
|
||||
insightResponsesIds: string[],
|
||||
limit?: number,
|
||||
offset?: number
|
||||
): Promise<TSurveyQuestionSummaryOpenText["insights"]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([surveyId, ZId], [questionId, ZSurveyQuestionId]);
|
||||
|
||||
limit = limit ?? INSIGHTS_PER_PAGE;
|
||||
try {
|
||||
const insights = await prisma.insight.findMany({
|
||||
where: {
|
||||
documentInsights: {
|
||||
some: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
...(insightResponsesIds.length > 0 && {
|
||||
responseId: {
|
||||
in: insightResponsesIds,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
documentInsights: {
|
||||
where: {
|
||||
document: {
|
||||
surveyId,
|
||||
questionId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
documentInsights: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
return insights;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getInsightsBySurveyIdQuestionId-${surveyId}-${questionId}-${limit}-${offset}`],
|
||||
{
|
||||
tags: [documentCache.tag.bySurveyId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,5 +1,4 @@
|
||||
import "server-only";
|
||||
import { getInsightsBySurveyIdQuestionId } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/insights";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { displayCache } from "@/lib/display/cache";
|
||||
@@ -317,11 +316,9 @@ export const getQuestionSummary = async (
|
||||
switch (question.type) {
|
||||
case TSurveyQuestionTypeEnum.OpenText: {
|
||||
let values: TSurveyQuestionSummaryOpenText["samples"] = [];
|
||||
const insightResponsesIds: string[] = [];
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[question.id];
|
||||
if (answer && typeof answer === "string") {
|
||||
insightResponsesIds.push(response.id);
|
||||
values.push({
|
||||
id: response.id,
|
||||
updatedAt: response.updatedAt,
|
||||
@@ -331,20 +328,12 @@ export const getQuestionSummary = async (
|
||||
});
|
||||
}
|
||||
});
|
||||
const insights = await getInsightsBySurveyIdQuestionId(
|
||||
survey.id,
|
||||
question.id,
|
||||
insightResponsesIds,
|
||||
50
|
||||
);
|
||||
|
||||
summary.push({
|
||||
type: question.type,
|
||||
question,
|
||||
responseCount: values.length,
|
||||
samples: values.slice(0, VALUES_LIMIT),
|
||||
insights,
|
||||
insightsEnabled: question.insightsEnabled,
|
||||
});
|
||||
|
||||
values = [];
|
||||
@@ -420,7 +409,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
value: label,
|
||||
count,
|
||||
@@ -519,7 +508,7 @@ export const getQuestionSummary = async (
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).map(([label, count]) => {
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: parseInt(label),
|
||||
count,
|
||||
|
||||