Compare commits

..

25 Commits

Author SHA1 Message Date
Piyush Gupta
07e074f8e4 fix: cursor comments 2025-07-03 22:16:04 +05:30
Piyush Gupta
e6fede577d Merge branch 'fix/duplicate-tag' into Naidu-4444/main 2025-07-03 22:05:49 +05:30
Piyush Gupta
ab2fc0ae2d fix: tests 2025-07-03 22:05:04 +05:30
Piyush Gupta
5b2964bc61 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/duplicate-tag 2025-07-03 21:36:42 +05:30
Piyush Gupta
40588c2a2e Merge branch 'main' of https://github.com/formbricks/formbricks into Naidu-4444/main 2025-07-03 21:35:35 +05:30
Piyush Gupta
dc8dbd7506 fix: tests 2025-07-03 21:32:29 +05:30
Piyush Gupta
a0601dc32a fix: unit tests 2025-07-03 19:01:15 +05:30
Piyush Gupta
dafad724ef fix: review suggestions 2025-07-03 17:55:14 +05:30
pandeymangg
2b20d0a6b2 refactors 2025-07-03 11:55:11 +05:30
Piyush Gupta
9181baea38 fix: coderabbit suggestions 2025-07-03 09:59:29 +05:30
Piyush Gupta
a278e1664a fix: review 2025-07-02 17:43:38 +05:30
Piyush Gupta
b78951cb79 fix: review suggestions and unit tests 2025-07-02 11:59:05 +05:30
Naidu_4444
c723e0169e fixed 2025-07-01 14:47:32 +00:00
Naidu_4444
36ad5f9442 prevent tag creation on Enter if tag exists + refactor tag update error handling 2025-07-01 13:45:22 +00:00
Aditya
8a432cfbe8 Merge branch 'formbricks:main' into main 2025-07-01 19:10:15 +05:30
Aditya
849cf79e88 Merge branch 'formbricks:main' into main 2025-06-30 15:30:00 +05:30
Aditya
c8d8e92f59 Merge branch 'formbricks:main' into main 2025-06-26 20:02:46 +05:30
Aditya
c4ac69ac94 Merge branch 'formbricks:main' into main 2025-06-25 10:49:43 +05:30
Piyush Gupta
22535d2d70 fix: unique check 2025-06-24 18:36:09 +05:30
Piyush Gupta
95838d7cd1 fix: unique check 2025-06-24 18:14:54 +05:30
Piyush Gupta
907899c3d6 fix: functionality 2025-06-24 18:12:58 +05:30
Piyush Gupta
ea8de20ff4 Merge branch 'main' of https://github.com/formbricks/formbricks into Naidu-4444/main 2025-06-24 17:53:27 +05:30
Naidu_4444
bc4a6f36e4 test: fix updateTagName error test to expect thrown error 2025-06-24 09:19:00 +00:00
Naidu_4444
05c48d88c9 fix: improve duplicate tag error handling 2025-06-24 08:21:26 +00:00
Naidu_4444
3f9f097260 fix: show specific error when duplicate tag name is entered 2025-06-23 11:51:05 +00:00
530 changed files with 13221 additions and 32702 deletions

View File

@@ -1,5 +1,5 @@
---
description: Migrate deprecated UI components to a unified component
description:
globs:
alwaysApply: false
---

View File

@@ -1,177 +0,0 @@
---
description: Create a story in Storybook for a given component
globs:
alwaysApply: false
---
# Formbricks Storybook Stories
## When generating Storybook stories for Formbricks components:
### 1. **File Structure**
- Create `stories.tsx` (not `.stories.tsx`) in component directory
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
- Import component from `"./index"`
### 2. **Story Structure Template**
```tsx
import { Meta, StoryObj } from "@storybook/react-vite";
import { ComponentName } from "./index";
// For complex components with configurable options
// consider this as an example the options need to reflect the props types
interface StoryOptions {
showIcon: boolean;
numberOfElements: number;
customLabels: string[];
}
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
const meta: Meta<StoryProps> = {
title: "UI/ComponentName",
component: ComponentName,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: { sort: "alpha", exclude: [] },
docs: {
description: {
component: "The **ComponentName** component provides [description].",
},
},
},
argTypes: {
// Organize in exactly these categories: Behavior, Appearance, Content
},
};
export default meta;
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
```
### 3. **ArgTypes Organization**
Organize ALL argTypes into exactly three categories:
- **Behavior**: disabled, variant, onChange, etc.
- **Appearance**: size, color, layout, styling, etc.
- **Content**: text, icons, numberOfElements, etc.
Format:
```tsx
argTypes: {
propName: {
control: "select" | "boolean" | "text" | "number",
options: ["option1", "option2"], // for select
description: "Clear description",
table: {
category: "Behavior" | "Appearance" | "Content",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
}
```
### 4. **Required Stories**
Every component must include:
- `Default`: Most common use case
- `Disabled`: If component supports disabled state
- `WithIcon`: If component supports icons
- Variant stories for each variant (Primary, Secondary, Error, etc.)
- Edge case stories (ManyElements, LongText, CustomStyling)
### 5. **Story Format**
```tsx
export const Default: Story = {
args: {
// Props with realistic values
},
};
export const EdgeCase: Story = {
args: { /* ... */ },
parameters: {
docs: {
description: {
story: "Use this when [specific scenario].",
},
},
},
};
```
### 6. **Dynamic Content Pattern**
For components with dynamic content, create render function:
```tsx
const renderComponent = (args: StoryProps) => {
const { numberOfElements, showIcon, customLabels } = args;
// Generate dynamic content
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
id: `element-${i}`,
label: customLabels[i] || `Element ${i + 1}`,
icon: showIcon ? <IconComponent /> : undefined,
}));
return <ComponentName {...args} elements={elements} />;
};
export const Dynamic: Story = {
render: renderComponent,
args: {
numberOfElements: 3,
showIcon: true,
customLabels: ["First", "Second", "Third"],
},
};
```
### 7. **State Management**
For interactive components:
```tsx
import { useState } from "react";
const ComponentWithState = (args: any) => {
const [value, setValue] = useState(args.defaultValue);
return (
<ComponentName
{...args}
value={value}
onChange={(newValue) => {
setValue(newValue);
args.onChange?.(newValue);
}}
/>
);
};
export const Interactive: Story = {
render: ComponentWithState,
args: { defaultValue: "initial" },
};
```
### 8. **Quality Requirements**
- Include component description in parameters.docs
- Add story documentation for non-obvious use cases
- Test edge cases (overflow, empty states, many elements)
- Ensure no TypeScript errors
- Use realistic prop values
- Include at least 3-5 story variants
- Example values need to be in the context of survey application
### 9. **Naming Conventions**
- **Story titles**: "UI/ComponentName"
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
- **Props**: camelCase matching component props
### 10. **Special Cases**
- **Generic components**: Remove `component` from meta if type conflicts
- **Form components**: Include Invalid, WithValue stories
- **Navigation**: Include ManyItems stories
- **Modals, Dropdowns and Popups **: Include trigger and content structure
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.

View File

@@ -189,11 +189,15 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY=
@@ -215,7 +219,7 @@ REDIS_URL=redis://localhost:6379
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Default 0.
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0

View File

@@ -1,7 +1,6 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Questions
url: https://github.com/formbricks/formbricks/discussions

View File

@@ -11,10 +11,6 @@ inputs:
sentry_auth_token:
description: 'Sentry authentication token'
required: true
environment:
description: 'Sentry environment (e.g., production, staging)'
required: false
default: 'staging'
runs:
using: 'composite'
@@ -111,7 +107,7 @@ runs:
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: ${{ inputs.environment }}
environment: production
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'

View File

@@ -17,8 +17,8 @@ on:
required: true
type: choice
options:
- staging
- production
- stage
- prod
workflow_call:
inputs:
VERSION:
@@ -52,7 +52,6 @@ jobs:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
@@ -67,8 +66,8 @@ jobs:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -85,8 +84,8 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -102,13 +101,13 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"

View File

@@ -89,7 +89,6 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -103,12 +102,6 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Run Rate Limiter Load Tests
run: |
echo "Running rate limiter load tests with Redis/Valkey..."
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -1,22 +1,17 @@
name: Build, release & deploy Formbricks images
on:
release:
types: [published]
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
workflow_dispatch:
push:
tags:
- "v*"
jobs:
docker-build:
name: Build & release docker image
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release:
name: Release Helm Chart
@@ -36,7 +31,7 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
@@ -59,4 +54,3 @@ jobs:
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
release_version: v${{ needs.docker-build.outputs.VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: ${{ env.ENVIRONMENT }}

View File

@@ -10,6 +10,8 @@ permissions:
on:
pull_request:
branches:
- main
merge_group:
workflow_dispatch:

View File

@@ -29,10 +29,6 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
outputs:
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -42,53 +38,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Generate SemVer version from branch or tag
id: generate_version
run: |
# Get reference name and type
REF_NAME="${{ github.ref_name }}"
REF_TYPE="${{ github.ref_type }}"
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
echo "Using SemVer tag: $VERSION"
else
# Tag is not SemVer, treat as prerelease
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_TAG"
echo "Using tag as prerelease: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
VERSION="0.0.0-$SANITIZED_BRANCH"
echo "Using branch as prerelease: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set Sentry environment in .env
run: |
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
echo "SENTRY_ENVIRONMENT=staging" >> .env
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
else
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
fi
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -134,21 +83,6 @@ jobs:
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Extract image info for sourcemap upload
id: extract_image_info
run: |
# Use the first readable tag from metadata action output
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
# Use the generated version for Sentry release
RELEASE_VERSION="$VERSION"
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
echo "Docker image: $DOCKER_IMAGE"
echo "Release version: $RELEASE_VERSION"
echo "Available tags: ${{ steps.meta.outputs.tags }}"
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
# repository is public to avoid leaking data. If you would like to publish
@@ -163,25 +97,3 @@ jobs:
# This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- build
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
environment: staging

View File

@@ -7,12 +7,6 @@ name: Docker Release to Github
on:
workflow_call:
inputs:
IS_PRERELEASE:
description: 'Whether this is a prerelease (affects latest tag)'
required: false
type: boolean
default: false
outputs:
VERSION:
description: release version
@@ -51,12 +45,10 @@ jobs:
- name: Get Release Tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version
run: |
@@ -89,13 +81,6 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action

View File

@@ -43,7 +43,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
run: |

View File

@@ -41,7 +41,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
run: pnpm test

View File

@@ -14,7 +14,17 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months.
Ready to dive into the code and make a real impact? Here's your path:
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features

View File

@@ -192,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
- Note: For the time being, we can only facilitate code contributions as an exception.
Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
## All Thanks To Our Contributors

View File

@@ -14,9 +14,10 @@ const config: StorybookConfig = {
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -1,21 +1,5 @@
import type { Preview } from "@storybook/react-vite";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
import type { Preview } from "@storybook/react";
import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
// Create a Storybook-specific Tolgee decorator
const withTolgee = (Story: any) => {
const tolgee = TolgeeBase().init({
tagNewKeys: [], // No branch tagging in Storybook
});
return React.createElement(
TolgeeProvider,
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -26,7 +10,6 @@ const preview: Preview = {
},
},
},
decorators: [withTolgee],
};
export default preview;

View File

@@ -14,19 +14,23 @@
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "9.0.15",
"vite": "6.3.5",
"@storybook/addon-docs": "9.0.15"
"storybook": "8.6.12",
"vite": "6.3.5"
}
}

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs/blocks";
import { Meta } from "@storybook/blocks";
import Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png";

View File

@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({

View File

@@ -1,5 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
@@ -8,40 +8,23 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock the Dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({
open,
onOpenChange,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
}) =>
open ? (
<div data-testid="dialog">
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
// Mock child components
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
<div data-testid="modal-with-tabs">
<span data-testid="modal-label">{label}</span>
<span data-testid="modal-description">{description}</span>
<span data-testid="modal-open">{open.toString()}</span>
<button onClick={() => setOpen(false)}>Close</button>
{icon}
{tabs.map((tab) => (
<div key={tab.title}>
<h2>{tab.title}</h2>
{tab.children}
</div>
))}
</div>
)),
}));
vi.mock("./ActionActivityTab", () => ({
@@ -61,22 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
},
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn();
@@ -122,68 +89,58 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test
});
test("renders correctly when open", () => {
test("renders ModalWithTabs with correct props", () => {
render(<ActionDetailModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
test("does not render when open is false", () => {
render(<ActionDetailModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
expect(mockedModalWithTabs).toHaveBeenCalled();
const props = mockedModalWithTabs.mock.calls[0][0];
test("switches tabs correctly", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// Check basic props
expect(props.open).toBe(true);
expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Initially shows activity tab (first tab is active)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
// Check icon data-testid based on the mock for the default 'code' type
expect(props.icon).toBeDefined();
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Click settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Check tabs structure
expect(props.tabs).toHaveLength(2);
expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Now shows settings tab content
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
// Check if the correct mocked components are used as children
// Access the mocked functions directly
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
// Click activity tab again
const activityTab = screen.getByText("Activity");
await user.click(activityTab);
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
// Back to activity tab content
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
test("resets to first tab when modal is reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
// Check props passed to child components
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
// Switch to settings tab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.actionClass).toBe(mockActionClass);
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
});
test("renders correct icon based on action type", () => {
@@ -191,68 +148,33 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument();
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
});
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
test("handles action without description", () => {
const actionWithoutDescription = { ...mockActionClass, description: "" };
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
// Expect the 'nocode-icon' based on the updated mock and action type
expect(props.icon).toBeDefined();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
});
if (!props.icon) {
throw new Error("Icon prop is not defined");
}
test("passes correct props to ActionActivityTab", () => {
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
});
test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
expect.objectContaining({
isReadOnly: true,
}),
undefined
);
if (!props.tabs[0].children || !props.tabs[1].children) {
throw new Error("Tabs children are not defined");
}
const activityTabProps = (props.tabs[0].children as any).props;
expect(activityTabProps.isReadOnly).toBe(true);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
});
});

View File

@@ -59,16 +59,6 @@ export const ActionDetailModal = ({
},
];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return (
<>
<ModalWithTabs
@@ -77,7 +67,7 @@ export const ActionDetailModal = ({
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
description={actionClass.description || ""}
/>
</>
);

View File

@@ -210,13 +210,14 @@ export const ActionSettingsTab = ({
)}
</div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
<div className="flex items-center gap-x-2">
<div className="flex justify-between border-t border-slate-200 py-6">
<div>
{!isReadOnly ? (
<Button
type="button"
variant="destructive"
onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger">
<TrashIcon />
{t("common.delete")}

View File

@@ -22,29 +22,14 @@ vi.mock("@/modules/ui/components/button", () => ({
),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, ...props }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
<div data-testid="modal" {...props}>
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
}));
vi.mock("@tolgee/react", () => ({
@@ -85,21 +70,17 @@ describe("AddActionModal", () => {
);
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("opens the dialog when the 'Add Action' button is clicked", async () => {
test("opens the modal when the 'Add Action' button is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect(
@@ -127,35 +108,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function);
});
test("closes the dialog when the close button (simulated) is clicked", async () => {
test("closes the modal when the close button (simulated) is clicked", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked Dialog's close button
const closeDialogButton = screen.getByText("Close Dialog");
await userEvent.click(closeDialogButton);
// Simulate closing via the mocked Modal's close button
const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeModalButton);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => {
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
);
const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
});

View File

@@ -2,14 +2,7 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react";
@@ -33,26 +26,36 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")}
<PlusIcon />
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent disableCloseOnOutsideClick>
<DialogHeader>
<MousePointerClickIcon />
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
<DialogDescription>
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</DialogDescription>
</DialogHeader>
<DialogBody>
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</DialogBody>
</DialogContent>
</Dialog>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<MousePointerClickIcon className="h-5 w-5" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.actions.track_new_user_action")}
</div>
<div className="text-sm text-slate-500">
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
</>
);
};

View File

@@ -101,7 +101,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
/>
<div className="flex h-full">

View File

@@ -1,157 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
// Mock environment data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "test-project-id",
appSetupCompleted: true,
};
// Mock project data
const mockProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "test-org-id",
config: {
channel: "app",
industry: "saas",
},
linkSurveyBranding: true,
styling: {
allowStyleOverwrite: true,
brandColor: {
light: "#ffffff",
dark: "#000000",
},
questionColor: {
light: "#000000",
dark: "#ffffff",
},
inputColor: {
light: "#000000",
dark: "#ffffff",
},
inputBorderColor: {
light: "#cccccc",
dark: "#444444",
},
cardBackgroundColor: {
light: "#ffffff",
dark: "#000000",
},
cardBorderColor: {
light: "#cccccc",
dark: "#444444",
},
isDarkModeEnabled: false,
isLogoHidden: false,
hideProgressBar: false,
roundness: 8,
cardArrangement: {
linkSurveys: "casual",
appSurveys: "casual",
},
},
recontactDays: 30,
inAppSurveyBranding: true,
logo: {
url: "test-logo.png",
bgColor: "#ffffff",
},
placement: "bottomRight",
clickOutsideClose: true,
} as TProject;
// Test component that uses the hook
const TestComponent = () => {
const { environment, project } = useEnvironment();
return (
<div>
<div data-testid="environment-id">{environment.id}</div>
<div data-testid="environment-type">{environment.type}</div>
<div data-testid="project-id">{project.id}</div>
<div data-testid="project-organization-id">{project.organizationId}</div>
</div>
);
};
describe("EnvironmentContext", () => {
afterEach(() => {
cleanup();
});
test("provides environment and project data to child components", () => {
render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
});
test("throws error when useEnvironment is used outside of provider", () => {
const TestComponentWithoutProvider = () => {
useEnvironment();
return <div>Should not render</div>;
};
expect(() => {
render(<TestComponentWithoutProvider />);
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
});
test("updates context value when environment or project changes", () => {
const { rerender } = render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
const updatedEnvironment = {
...mockEnvironment,
type: "production" as const,
};
rerender(
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
});
test("memoizes context value correctly", () => {
const { rerender } = render(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
// Re-render with same props
rerender(
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
<TestComponent />
</EnvironmentContextWrapper>
);
// Should still work correctly
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
});
});

View File

@@ -1,47 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};

View File

@@ -92,24 +92,14 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen }) =>
open ? (
<div data-testid="dialog" role="dialog">
<div data-testid="modal">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
<button onClick={() => setOpen(false)}>Close Modal</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>,

View File

@@ -10,16 +10,8 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import {
Select,
SelectContent,
@@ -27,11 +19,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { TFnType, useTranslate } from "@tolgee/react";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Control, Controller, useForm } from "react-hook-form";
import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration";
import {
@@ -76,80 +68,6 @@ const NoBaseFoundError = () => {
);
};
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({
open,
setOpenWithStates,
@@ -292,148 +210,182 @@ export const AddIntegrationModal = ({
};
return (
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent className="overflow-visible md:overflow-visible">
<DialogHeader>
<Modal open={open} setOpen={handleClose} noPadding>
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.airtable.link_airtable_table")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.airtable.sync_responses_with_airtable")}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
<DialogBody className="overflow-visible">
<div className="flex w-full flex-col gap-y-4">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
</div>
</div>
<form onSubmit={handleSubmit(submitHandler)}>
<div className="flex rounded-lg p-6">
<div className="flex w-full flex-col gap-y-4 pt-5">
{airtableArray.length ? (
<BaseSelectDropdown
control={control}
isLoading={isLoading}
fetchTable={fetchTable}
airtableArray={airtableArray}
setValue={setValue}
defaultValue={defaultData?.base}
/>
) : (
<NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="table"
name="survey"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.table}>
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</div>
</div>
) : null}
{surveys.length ? (
<div className="flex w-full flex-col">
<Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="survey"
render={({ field }) => (
<Select
required
onValueChange={(val) => {
field.onChange(val);
setValue("questions", []);
}}
defaultValue={defaultData?.survey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{surveys.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{!surveys.length ? (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
) : null}
{survey && selectedSurvey && (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
) : (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
</p>
)}
{survey &&
selectedSurvey &&
renderQuestionSelection({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div>
</DialogBody>
<DialogFooter>
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
)}
<Button type="submit">{t("common.save")}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<div className="flex justify-end gap-x-2">
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</div>
</div>
</div>
</form>
</Modal>
);
};

View File

@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale;
}
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
{header}
{t(header)}
</div>
))}
</div>

View File

@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -88,24 +88,9 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -319,9 +304,10 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -346,9 +332,10 @@ describe("AddIntegrationModal", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input
expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")

View File

@@ -14,18 +14,10 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import Image from "next/image";
import { useEffect, useState } from "react";
@@ -210,28 +202,31 @@ export const AddIntegrationModal = ({
};
return (
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.google_sheets.link_google_sheet")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
</div>
</div>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
<DialogBody>
</div>
<form onSubmit={handleSubmit(linkSheet)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -297,37 +292,39 @@ export const AddIntegrationModal = ({
</div>
)}
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</Modal>
);
};

View File

@@ -74,41 +74,13 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
XIcon: () => <span data-testid="x-icon">x</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -362,7 +334,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -387,7 +359,7 @@ describe("AddIntegrationModal (Notion)", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -409,7 +381,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
});
expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -473,8 +445,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
await userEvent.click(trashButton);
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(xButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
});

View File

@@ -12,19 +12,11 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import { PlusIcon, XIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
@@ -344,9 +336,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<div className="w-[340px] max-w-full">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems}
@@ -392,7 +384,7 @@ export const AddIntegrationModal = ({
/>
</div>
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<div className="w-[340px] max-w-full">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
@@ -438,45 +430,53 @@ export const AddIntegrationModal = ({
/>
</div>
</div>
<div className="flex space-x-2">
{mapping.length > 1 && (
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
<TrashIcon />
</Button>
)}
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
<PlusIcon />
</Button>
</div>
<button
type="button"
className={`rounded-md p-1 hover:bg-slate-300 ${
idx === mapping.length - 1 ? "visible" : "invisible"
}`}
onClick={addRow}>
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
</button>
<button
type="button"
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<div className="mb-4 flex items-start space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
</DialogDescription>
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image
className="w-12"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
/>
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.notion.link_notion_database")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
<DialogBody>
</div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-1 space-y-2 overflow-y-auto">
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
@@ -530,40 +530,43 @@ export const AddIntegrationModal = ({
)}
</div>
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.notion.link_database")}
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</Modal>
);
};

View File

@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -83,24 +83,9 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="modal">{children}</div> : null,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
@@ -136,8 +121,6 @@ vi.mock("@tolgee/react", async () => {
if (key === "common.all_questions") return "All questions";
if (key === "common.selected_questions") return "Selected questions";
if (key === "environments.integrations.slack.link_slack_channel") return "Link Slack Channel";
if (key === "environments.integrations.slack.slack_integration_description")
return "Send responses directly to Slack.";
if (key === "common.update") return "Update";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
@@ -329,9 +312,10 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toBeInTheDocument();
expect(screen.getByTestId("survey-dropdown")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
@@ -355,9 +339,10 @@ describe("AddChannelMappingModal", () => {
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Slack Channel");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Send responses directly to Slack.");
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(
screen.getByText("Link Slack Channel", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
expect(screen.getByTestId("channel-dropdown")).toHaveValue(channels[0].id);
expect(screen.getByTestId("survey-dropdown")).toHaveValue(surveys[0].id);
expect(screen.getByText("Questions")).toBeInTheDocument();

View File

@@ -7,17 +7,9 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { CircleHelpIcon } from "lucide-react";
import Image from "next/image";
@@ -197,28 +189,24 @@ export const AddChannelMappingModal = ({
);
return (
<Dialog open={open} onOpenChange={setOpenWithStates}>
<DialogContent>
<DialogHeader>
<div className="flex items-center space-x-2">
<div className="relative size-8">
<Image
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
</DialogDescription>
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<div className="flex h-full flex-col rounded-lg">
<div className="rounded-t-lg bg-slate-100">
<div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<Image className="w-12" src={SlackLogo} alt="Slack logo" />
</div>
<div>
<div className="text-xl font-medium text-slate-700">
{t("environments.integrations.slack.link_slack_channel")}
</div>
</div>
</div>
</div>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(linkChannel)}>
<DialogBody>
</div>
<form onSubmit={handleSubmit(linkChannel)}>
<div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4">
<div>
<div className="mb-4">
@@ -301,29 +289,31 @@ export const AddChannelMappingModal = ({
</div>
)}
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</div>
<div className="flex justify-end border-t border-slate-200 p-6">
<div className="flex space-x-2">
{selectedIntegration ? (
<Button type="button" variant="destructive" loading={isDeleting} onClick={deleteLink}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</div>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</Modal>
);
};

View File

@@ -1,4 +1,3 @@
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -6,7 +5,6 @@ import { cleanup, render, screen } from "@testing-library/react";
import { Session } from "next-auth";
import { redirect } from "next/navigation";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
@@ -15,20 +13,12 @@ import EnvLayout from "./layout";
// Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children, environmentId, session }: any) => (
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
{children}
</div>
),
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
}));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
<div
data-testid="EnvironmentIdBaseLayout"
data-environment-id={environmentId}
data-session={session?.user?.id}
data-user={user?.id}
data-organization={organization?.id}>
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
{children}
</div>
),
@@ -37,24 +27,7 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => (
<div data-testid="EnvironmentStorageHandler" data-environment-id={environmentId} />
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/context/environment-context", () => ({
EnvironmentContextWrapper: ({ children, environment, project }: any) => (
<div
data-testid="EnvironmentContextWrapper"
data-environment-id={environment?.id}
data-project-id={project?.id}>
{children}
</div>
),
}));
// Mock navigation
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
}));
// Mocks for dependencies
@@ -64,43 +37,26 @@ vi.mock("@/modules/environments/lib/utils", () => ({
vi.mock("@/lib/project/service", () => ({
getProjectByEnvironmentId: vi.fn(),
}));
vi.mock("@/lib/environment/service", () => ({
getEnvironment: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
describe("EnvLayout", () => {
const mockSession = { user: { id: "user1" } } as Session;
const mockUser = { id: "user1", email: "user1@example.com" } as TUser;
const mockOrganization = { id: "org1", name: "Org1", billing: {} } as TOrganization;
const mockProject = { id: "proj1", name: "Test Project" } as TProject;
const mockEnvironment = { id: "env1", type: "production" } as TEnvironment;
const mockMembership = {
id: "member1",
role: "owner",
organizationId: "org1",
userId: "user1",
accepted: true,
} as TMembership;
const mockTranslation = ((key: string) => key) as any;
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders successfully when all dependencies return valid data", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
@@ -108,43 +64,56 @@ describe("EnvLayout", () => {
});
render(result);
// Verify main layout structure
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-session", "user1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-user", "user1");
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveAttribute("data-organization", "org1");
// Verify environment storage handler
expect(screen.getByTestId("EnvironmentStorageHandler")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveAttribute("data-environment-id", "env1");
// Verify context wrapper
expect(screen.getByTestId("EnvironmentContextWrapper")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-project-id", "proj1");
// Verify environment layout
expect(screen.getByTestId("EnvironmentLayout")).toBeInTheDocument();
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("EnvironmentLayout")).toHaveAttribute("data-session", "user1");
// Verify children are rendered
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
expect(screen.getByTestId("child")).toHaveTextContent("Content");
// Verify all services were called with correct parameters
expect(environmentIdLayoutChecks).toHaveBeenCalledWith("env1");
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
});
test("redirects when session is null", async () => {
test("throws error if project is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: null as unknown as Session,
user: mockUser,
organization: mockOrganization,
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.project_not_found");
});
test("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: { id: "user1", email: "user1@example.com" } as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.membership_not_found");
});
test("calls redirect when session is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: ((key: string) => key) as any,
session: undefined as unknown as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
@@ -156,16 +125,18 @@ describe("EnvLayout", () => {
children: <div>Content</div>,
})
).rejects.toThrow("Redirect called");
expect(redirect).toHaveBeenCalledWith("/auth/login");
});
test("throws error if user is null", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: null as unknown as TUser,
organization: mockOrganization,
t: ((key: string) => key) as any,
session: { user: { id: "user1" } } as Session,
user: undefined as unknown as TUser,
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
});
await expect(
@@ -174,154 +145,5 @@ describe("EnvLayout", () => {
children: <div>Content</div>,
})
).rejects.toThrow("common.user_not_found");
// Verify redirect was not called
expect(redirect).not.toHaveBeenCalled();
});
test("throws error if project is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.project_not_found");
// Verify both project and environment were called in Promise.all
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
});
test("throws error if environment is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.environment_not_found");
// Verify both project and environment were called in Promise.all
expect(getProjectByEnvironmentId).toHaveBeenCalledWith("env1");
expect(getEnvironment).toHaveBeenCalledWith("env1");
});
test("throws error if membership is not found", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
await expect(
EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div>Content</div>,
})
).rejects.toThrow("common.membership_not_found");
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
});
test("handles Promise.all correctly for project and environment", async () => {
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
// Mock Promise.all to verify it's called correctly
const getProjectSpy = vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
const getEnvironmentSpy = vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify both calls were made
expect(getProjectSpy).toHaveBeenCalledWith("env1");
expect(getEnvironmentSpy).toHaveBeenCalledWith("env1");
// Verify successful rendering
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("handles different environment types correctly", async () => {
const developmentEnvironment = { id: "env1", type: "development" } as TEnvironment;
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(developmentEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify context wrapper receives the development environment
expect(screen.getByTestId("EnvironmentContextWrapper")).toHaveAttribute("data-environment-id", "env1");
expect(screen.getByTestId("child")).toBeInTheDocument();
});
test("handles different user roles correctly", async () => {
const memberMembership = {
id: "member1",
role: "member",
organizationId: "org1",
userId: "user1",
accepted: true,
} as TMembership;
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(memberMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
children: <div data-testid="child">Content</div>,
});
render(result);
// Verify successful rendering with member role
expect(screen.getByTestId("child")).toBeInTheDocument();
expect(getMembershipByUserIdOrganizationId).toHaveBeenCalledWith("user1", "org1");
});
});

View File

@@ -1,6 +1,4 @@
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getEnvironment } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
@@ -13,6 +11,7 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
@@ -25,19 +24,11 @@ const EnvLayout = async (props: {
throw new Error(t("common.user_not_found"));
}
const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(params.environmentId),
]);
const project = await getProjectByEnvironmentId(params.environmentId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
if (!environment) {
throw new Error(t("common.environment_not_found"));
}
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
if (!membership) {
@@ -51,11 +42,9 @@ const EnvLayout = async (props: {
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper environment={environment} project={project}>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</EnvironmentContextWrapper>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</EnvironmentIdBaseLayout>
);
};

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -10,19 +10,24 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
import { getUser, updateUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { rateLimit } from "@/lib/utils/rate-limit";
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
TUserPersonalInfoUpdateInput,
TUserUpdateInput,
ZUserPersonalInfoUpdateInput,
} from "@formbricks/types/user";
AuthenticationError,
AuthorizationError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
const limiter = rateLimit({
interval: 60 * 60, // 1 hour
allowedPerInterval: 3, // max 3 calls for email verification per hour
});
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
return {
@@ -36,15 +41,18 @@ async function handleEmailUpdate({
parsedInput,
payload,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
ctx: any;
parsedInput: any;
payload: TUserUpdateInput;
}) {
const inputEmail = parsedInput.email?.trim().toLowerCase();
if (!inputEmail || ctx.user.email === inputEmail) return payload;
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
try {
await limiter(ctx.user.id);
} catch {
throw new TooManyRequestsError("Too many requests");
}
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
}
@@ -67,35 +75,41 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: TUserPersonalInfoUpdateInput;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
export const updateUserAction = authenticatedActionClient
.schema(
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
password: ZUserPassword.optional(),
})
)
);
.action(
withAuditLogging(
"updated",
"user",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const oldObject = await getUser(ctx.user.id);
let payload = buildUserUpdatePayload(parsedInput);
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
// Only proceed with updateUser if we have actual changes to make
let newObject = oldObject;
if (Object.keys(payload).length > 0) {
newObject = await updateUser(ctx.user.id, payload);
}
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = newObject;
return true;
}
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
@@ -155,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
}
await sendForgotPasswordEmail(ctx.user);

View File

@@ -145,7 +145,7 @@ export const EditProfileDetailsForm = ({
});
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
toast.error(t(errorMessage));
}
setIsResettingPassword(false);

View File

@@ -4,27 +4,18 @@ import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PasswordConfirmationModal } from "./password-confirmation-modal";
// Mock the Dialog component
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ children, open, onOpenChange }: any) =>
// Mock the Modal component
vi.mock("@/modules/ui/components/modal", () => ({
Modal: ({ children, open, setOpen, title }: any) =>
open ? (
<div data-testid="dialog" role="dialog">
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
<button data-testid="modal-close" onClick={() => setOpen(false)}>
Close
</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
}));
// Mock the PasswordInput component
@@ -63,13 +54,13 @@ describe("PasswordConfirmationModal", () => {
test("renders nothing when open is false", () => {
render(<PasswordConfirmationModal {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("renders dialog content when open is true", () => {
test("renders modal content when open is true", () => {
render(<PasswordConfirmationModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("modal-title")).toBeInTheDocument();
});
test("displays old and new email addresses", () => {

View File

@@ -1,16 +1,8 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
import { Modal } from "@/modules/ui/components/modal";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
@@ -62,69 +54,64 @@ export const PasswordConfirmationModal = ({
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("auth.forgot-password.reset.confirm_password")}</DialogTitle>
<DialogDescription>{t("auth.email-change.confirm_password_description")}</DialogDescription>
</DialogHeader>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<DialogBody>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<Modal open={open} setOpen={setOpen} title={t("auth.forgot-password.reset.confirm_password")}>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<p className="text-muted-foreground text-sm">
{t("auth.email-change.confirm_password_description")}
</p>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
</div>
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
<div className="flex flex-col gap-2 text-sm sm:flex-row sm:justify-between sm:gap-4">
<p>
<strong>{t("auth.email-change.old_email")}:</strong>
<br /> {oldEmail.toLowerCase()}
</p>
<p>
<strong>{t("auth.email-change.new_email")}:</strong>
<br /> {newEmail.toLowerCase()}
</p>
</div>
<FormField
control={form.control}
name="password"
render={({ field, fieldState: { error } }) => (
<FormItem className="w-full">
<FormControl>
<div>
<PasswordInput
id="password"
autoComplete="current-password"
placeholder="*******"
aria-placeholder="password"
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
</FormControl>
</FormItem>
)}
/>
<div className="mt-4 space-x-2 text-right">
<Button type="button" variant="secondary" onClick={handleCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
variant="default"
loading={isSubmitting}
disabled={isSubmitting || !isDirty || oldEmail.toLowerCase() === newEmail.toLowerCase()}>
{t("common.confirm")}
</Button>
</div>
</form>
</FormProvider>
</Modal>
);
};

View File

@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -2,7 +2,6 @@
import { cn } from "@/lib/cn";
import { Badge } from "@/modules/ui/components/badge";
import { H3, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
export const SettingsCard = ({
@@ -32,7 +31,7 @@ export const SettingsCard = ({
id={title}>
<div className="border-b border-slate-200 px-4 pb-4">
<div className="flex">
<H3 className="capitalize">{title}</H3>
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (
@@ -40,9 +39,7 @@ export const SettingsCard = ({
)}
</div>
</div>
<Small color="muted" margin="headerDescription">
{description}
</Small>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
</div>

View File

@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -26,26 +26,8 @@ vi.mock("@/modules/ui/components/button", () => ({
)),
}));
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, hideCloseButton, width, className }) => (
<div
data-testid="dialog-content"
data-hide-close-button={hideCloseButton}
data-width={width}
className={className}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
DialogFooter: vi.fn(({ children }) => <div data-testid="dialog-footer">{children}</div>),
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
}));
const mockResponses = [
@@ -181,12 +163,12 @@ describe("ResponseCardModal", () => {
test("should not render if selectedResponseId is null", () => {
const { container } = render(<ResponseCardModal {...defaultProps} selectedResponseId={null} />);
expect(container.firstChild).toBeNull();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
});
test("should render the dialog when a response is selected", () => {
test("should render the modal when a response is selected", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("single-response-card")).toBeInTheDocument();
});
@@ -222,6 +204,14 @@ describe("ResponseCardModal", () => {
expect(nextButton).toBeDisabled();
});
test("should call setSelectedResponseId with null when close button is clicked", async () => {
render(<ResponseCardModal {...defaultProps} />);
const buttons = screen.getAllByTestId("mock-button");
const closeButton = buttons.find((button) => button.querySelector("svg.lucide-x"));
if (closeButton) await userEvent.click(closeButton);
expect(mockSetSelectedResponseId).toHaveBeenCalledWith(null);
});
test("useEffect should set open to true and currentIndex when selectedResponseId is provided", () => {
render(<ResponseCardModal {...defaultProps} selectedResponseId={mockResponses[1].id} />);
expect(mockSetOpen).toHaveBeenCalledWith(true);
@@ -239,10 +229,11 @@ describe("ResponseCardModal", () => {
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("should render ChevronLeft and ChevronRight icons", () => {
test("should render ChevronLeft, ChevronRight, and XIcon", () => {
render(<ResponseCardModal {...defaultProps} />);
expect(document.querySelector(".lucide-chevron-left")).toBeInTheDocument();
expect(document.querySelector(".lucide-chevron-right")).toBeInTheDocument();
expect(document.querySelector(".lucide-x")).toBeInTheDocument();
});
});

View File

@@ -1,7 +1,7 @@
import { SingleResponseCard } from "@/modules/analysis/components/SingleResponseCard";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent, DialogFooter } from "@/modules/ui/components/dialog";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Modal } from "@/modules/ui/components/modal";
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -64,20 +64,42 @@ export const ResponseCardModal = ({
}
};
const handleClose = (open: boolean) => {
setOpen(open);
if (!open) {
setSelectedResponseId(null);
}
const handleClose = () => {
setSelectedResponseId(null);
};
// If no response is selected or currentIndex is null, do not render the modal
if (selectedResponseId === null || currentIndex === null) return null;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent width="wide">
<DialogBody>
<Modal
hideCloseButton
open={open}
setOpen={setOpen}
size="xxl"
className="max-h-[80vh] overflow-auto"
noPadding>
<div className="h-full rounded-lg">
<div className="relative h-full w-full overflow-auto p-4">
<div className="mb-4 flex items-center justify-end space-x-2">
<Button
onClick={handleBack}
disabled={currentIndex === 0}
variant="ghost"
className="border bg-white p-2">
<ChevronLeft className="h-5 w-5" />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="ghost"
className="border bg-white p-2">
<ChevronRight className="h-5 w-5" />
</Button>
<Button className="border bg-white p-2" onClick={handleClose} variant="ghost">
<XIcon className="h-5 w-5" />
</Button>
</div>
<SingleResponseCard
survey={survey}
response={responses[currentIndex]}
@@ -91,20 +113,8 @@ export const ResponseCardModal = ({
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>
</DialogBody>
<DialogFooter>
<Button onClick={handleBack} disabled={currentIndex === 0} variant="outline" size="icon">
<ChevronLeft />
</Button>
<Button
onClick={handleNext}
disabled={currentIndex === responses.length - 1}
variant="outline"
size="icon">
<ChevronRight />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</Modal>
);
};

View File

@@ -3,7 +3,6 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -74,10 +73,6 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -183,7 +178,6 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -212,8 +206,6 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);

View File

@@ -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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
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 { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } 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";
@@ -36,12 +33,8 @@ const Page = async (props) => {
const tags = await getTagsByEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -58,10 +51,6 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
}>
<SurveyAnalysisNavigation environmentId={environment.id} survey={survey} activeId="responses" />

View File

@@ -1,24 +1,18 @@
"use server";
import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/emailTemplate";
import { WEBAPP_URL } from "@/lib/constants";
import { putFile } from "@/lib/storage/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { convertToCsv } from "@/lib/utils/file-conversion";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
import { ResourceNotFoundError } from "@formbricks/types/errors";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -203,61 +197,6 @@ export const deleteResultShareUrlAction = authenticatedActionClient
)
);
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
return {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
)
);
const ZGetEmailHtmlAction = z.object({
surveyId: ZId,
});
@@ -283,128 +222,3 @@ export const getEmailHtmlAction = authenticatedActionClient
return await getEmailTemplateHtml(parsedInput.surveyId, ctx.user.locale);
});
const ZGeneratePersonalLinksAction = z.object({
surveyId: ZId,
segmentId: ZId,
environmentId: ZId,
expirationDays: z.number().optional(),
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
// Get contacts and generate personal links
const contactsResult = await generatePersonalLinks(
parsedInput.surveyId,
parsedInput.segmentId,
parsedInput.expirationDays
);
if (!contactsResult || contactsResult.length === 0) {
throw new UnknownError("No contacts found for the selected segment");
}
// Prepare CSV data with the specified headers and order
const csvHeaders = [
"Formbricks Contact ID",
"User ID",
"First Name",
"Last Name",
"Email",
"Personal Link",
];
const csvData = contactsResult
.map((contact) => {
if (!contact) {
return null;
}
const attributes = contact.attributes ?? {};
return {
"Formbricks Contact ID": contact.contactId,
"User ID": attributes.userId ?? "",
"First Name": attributes.firstName ?? "",
"Last Name": attributes.lastName ?? "",
Email: attributes.email ?? "",
"Personal Link": contact.surveyUrl,
};
})
.filter((contact) => contact !== null);
// Convert to CSV using the file conversion utility
const csvContent = await convertToCsv(csvHeaders, csvData);
const fileName = `personal-links-${parsedInput.surveyId}-${Date.now()}.csv`;
// Store file temporarily and return download URL
const fileBuffer = Buffer.from(csvContent);
await putFile(fileName, fileBuffer, "private", parsedInput.environmentId);
const downloadUrl = `${WEBAPP_URL}/storage/${parsedInput.environmentId}/private/${fileName}`;
return {
downloadUrl,
fileName,
count: csvData.length,
};
});
const ZUpdateSingleUseLinksAction = z.object({
surveyId: ZId,
environmentId: ZId,
isSingleUse: z.boolean(),
isSingleUseEncryption: z.boolean(),
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const updatedSurvey = await updateSurvey({
...survey,
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
});
return updatedSurvey;
});

View File

@@ -0,0 +1,341 @@
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LucideIcon } from "lucide-react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveySingleUse,
} from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
// Mock data
const mockSurveyWeb = {
id: "survey1",
name: "Web Survey",
environmentId: "env1",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: true,
} as unknown as TSurveyQuestion,
],
displayOption: "displayOnce",
recontactDays: 0,
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
triggers: [],
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
styling: null,
} as unknown as TSurvey;
vi.mock("@/lib/constants", () => ({
INTERCOM_SECRET_KEY: "test-secret-key",
IS_INTERCOM_CONFIGURED: true,
INTERCOM_APP_ID: "test-app-id",
ENCRYPTION_KEY: "test-encryption-key",
ENTERPRISE_LICENSE_KEY: "test-enterprise-license-key",
GITHUB_ID: "test-github-id",
GITHUB_SECRET: "test-githubID",
GOOGLE_CLIENT_ID: "test-google-client-id",
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
AZUREAD_CLIENT_ID: "test-azuread-client-id",
AZUREAD_CLIENT_SECRET: "test-azure",
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
OIDC_DISPLAY_NAME: "test-oidc-display-name",
OIDC_CLIENT_ID: "test-oidc-client-id",
OIDC_ISSUER: "test-oidc-issuer",
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
WEBAPP_URL: "test-webapp-url",
IS_POSTHOG_CONFIGURED: true,
POSTHOG_API_HOST: "test-posthog-api-host",
POSTHOG_API_KEY: "test-posthog-api-key",
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
IS_FORMBRICKS_CLOUD: false,
}));
const mockSurveyLink = {
...mockSurveyWeb,
id: "survey2",
name: "Link Survey",
type: "link",
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
} as unknown as TSurvey;
const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
role: "project_manager",
objective: "other",
createdAt: new Date(),
updatedAt: new Date(),
locale: "en-US",
} as unknown as TUser;
// Mocks
const mockRouterRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRouterRefresh,
}),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (str: string) => str,
}),
}));
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
}));
vi.mock("@/modules/ui/components/badge", () => ({
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
}));
const mockEmbedViewComponent = vi.fn();
vi.mock("./shareEmbedModal/EmbedView", () => ({
EmbedView: (props: any) => mockEmbedViewComponent(props),
}));
const mockPanelInfoViewComponent = vi.fn();
vi.mock("./shareEmbedModal/PanelInfoView", () => ({
PanelInfoView: (props: any) => mockPanelInfoViewComponent(props),
}));
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
vi.mock("@/modules/ui/components/dialog", async () => {
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
"@/modules/ui/components/dialog"
);
return {
...actual,
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
capturedDialogOnOpenChange = props.onOpenChange;
return <actual.Dialog {...props} />;
},
// DialogTitle, DialogContent, DialogDescription will be the actual components
// due to ...actual spread and no specific mock for them here.
};
});
describe("ShareEmbedSurvey", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
capturedDialogOnOpenChange = undefined;
});
const mockSetOpen = vi.fn();
const defaultProps = {
survey: mockSurveyWeb,
publicDomain: "https://public-domain.com",
open: true,
modalView: "start" as "start" | "embed" | "panel",
setOpen: mockSetOpen,
user: mockUser,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ handleInitialPageButton, tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<button onClick={() => handleInitialPageButton()}>EmbedViewMockContent</button>
<div data-testid="embedview-tabs">{JSON.stringify(tabs)}</div>
<div data-testid="embedview-activeid">{activeId}</div>
<div data-testid="embedview-survey-id">{survey.id}</div>
<div data-testid="embedview-email">{email}</div>
<div data-testid="embedview-surveyUrl">{surveyUrl}</div>
<div data-testid="embedview-publicDomain">{publicDomain}</div>
<div data-testid="embedview-locale">{locale}</div>
</div>
)
);
mockPanelInfoViewComponent.mockImplementation(({ handleInitialPageButton }) => (
<button onClick={() => handleInitialPageButton()}>PanelInfoViewMockContent</button>
));
});
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} />);
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.getByText("ShareSurveyLinkMock")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("renders initial 'start' view correctly when open and modalView is 'start' for app survey", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} />);
// For app surveys, ShareSurveyLink should not be rendered
expect(screen.queryByText("ShareSurveyLinkMock")).not.toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.whats_next")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.embed_survey")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.configure_alerts")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_integrations")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
});
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const embedButton = screen.getByText("environments.surveys.summary.embed_survey");
await userEvent.click(embedButton);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
});
test("switches to 'panel' view when 'Send to panel' button is clicked", async () => {
render(<ShareEmbedSurvey {...defaultProps} />);
const panelButton = screen.getByText("environments.surveys.summary.send_to_panel");
await userEvent.click(panelButton);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("returns to 'start' view when handleInitialPageButton is triggered from EmbedView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
const embedViewButton = screen.getByText("EmbedViewMockContent");
await userEvent.click(embedViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("returns to 'start' view when handleInitialPageButton is triggered from PanelInfoView", async () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
const panelInfoViewButton = screen.getByText("PanelInfoViewMockContent");
await userEvent.click(panelInfoViewButton);
// Should go back to start view, not close the modal
expect(screen.getByText("environments.surveys.summary.your_survey_is_public 🎉")).toBeInTheDocument();
expect(screen.queryByText("PanelInfoViewMockContent")).not.toBeInTheDocument();
expect(mockSetOpen).not.toHaveBeenCalled();
});
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} survey={mockSurveyWeb} />);
expect(capturedDialogOnOpenChange).toBeDefined();
// Simulate Dialog closing
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
expect(mockSetOpen).toHaveBeenCalledWith(false);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
// Simulate Dialog opening
mockRouterRefresh.mockClear();
mockSetOpen.mockClear();
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
expect(mockSetOpen).toHaveBeenCalledWith(true);
expect(mockRouterRefresh).toHaveBeenCalledTimes(1);
});
test("correctly configures for 'link' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(3);
expect(embedViewProps.tabs.find((tab) => tab.id === "app")).toBeUndefined();
expect(embedViewProps.tabs[0].id).toBe("link");
expect(embedViewProps.activeId).toBe("link");
});
test("correctly configures for 'web' survey type in embed view", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />);
const embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string; icon: LucideIcon }[];
activeId: string;
};
expect(embedViewProps.tabs.length).toBe(1);
expect(embedViewProps.tabs[0].id).toBe("app");
expect(embedViewProps.activeId).toBe("app");
});
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
const { rerender } = render(
<ShareEmbedSurvey {...defaultProps} survey={mockSurveyWeb} modalView="embed" />
);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[0][0].activeId).toBe("app");
rerender(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
expect(vi.mocked(mockEmbedViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
});
test("initial showView is set by modalView prop when open is true", () => {
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(mockEmbedViewComponent).toHaveBeenCalled();
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
expect(mockPanelInfoViewComponent).toHaveBeenCalled();
expect(screen.getByText("PanelInfoViewMockContent")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByText("EmbedViewMockContent")).toBeInTheDocument(); // Starts in embed
rerender(<ShareEmbedSurvey {...defaultProps} open={false} modalView="embed" />);
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
expect(screen.queryByText("EmbedViewMockContent")).not.toBeInTheDocument();
// To verify showView is 'start', we'd need to inspect internal state or render start view elements
// For now, we trust the useEffect sets showView, and if it were to re-open in 'start' mode, it would show.
// The main check is that the previous view ('embed') is gone.
});
test("renders correct label for link tab based on singleUse survey property", () => {
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLink} modalView="embed" />);
let embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
let linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.share_the_link");
cleanup();
vi.mocked(mockEmbedViewComponent).mockClear();
const mockSurveyLinkSingleUse: TSurvey = {
...mockSurveyLink,
singleUse: { enabled: true, isEncrypted: true },
};
render(<ShareEmbedSurvey {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="embed" />);
embedViewProps = vi.mocked(mockEmbedViewComponent).mock.calls[0][0] as {
tabs: { id: string; label: string }[];
};
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
});
});

View File

@@ -0,0 +1,189 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Badge } from "@/modules/ui/components/badge";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
import {
BellRing,
BlocksIcon,
Code2Icon,
LinkIcon,
MailIcon,
SmartphoneIcon,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { EmbedView } from "./shareEmbedModal/EmbedView";
import { PanelInfoView } from "./shareEmbedModal/PanelInfoView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
publicDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
}
export const ShareEmbedSurvey = ({
survey,
publicDomain,
open,
modalView,
setOpen,
user,
}: ShareEmbedSurveyProps) => {
const router = useRouter();
const environmentId = survey.environmentId;
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
const { email } = user;
const { t } = useTranslate();
const tabs = useMemo(
() =>
[
{
id: "link",
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
icon: LinkIcon,
},
{ id: "email", label: t("environments.surveys.summary.embed_in_an_email"), icon: MailIcon },
{ id: "webpage", label: t("environments.surveys.summary.embed_on_website"), icon: Code2Icon },
{ id: "app", label: t("environments.surveys.summary.embed_in_app"), icon: SmartphoneIcon },
].filter((tab) => !(survey.type === "link" && tab.id === "app")),
[t, isSingleUseLinkSurvey, survey.type]
);
const [activeId, setActiveId] = useState(survey.type === "link" ? tabs[0].id : tabs[3].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel">("start");
const [surveyUrl, setSurveyUrl] = useState("");
useEffect(() => {
const fetchSurveyUrl = async () => {
try {
const url = await getSurveyUrl(survey, publicDomain, "default");
setSurveyUrl(url);
} catch (error) {
console.error("Failed to fetch survey URL:", error);
// Fallback to a default URL if fetching fails
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
}
};
fetchSurveyUrl();
}, [survey, publicDomain]);
useEffect(() => {
if (survey.type !== "link") {
setActiveId(tabs[3].id);
}
}, [survey.type, tabs]);
useEffect(() => {
if (open) {
setShowView(modalView);
} else {
setShowView("start");
}
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setActiveId(survey.type === "link" ? tabs[0].id : tabs[3].id);
setOpen(open);
if (!open) {
setShowView("start");
}
router.refresh();
};
const handleInitialPageButton = () => {
setShowView("start");
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="flex h-full max-w-full flex-col overflow-hidden">
{survey.type === "link" && (
<div className="flex h-2/5 w-full flex-col items-center justify-center space-y-6 p-8 text-center">
<DialogTitle>
<p className="pt-2 text-xl font-semibold text-slate-800">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
</DialogTitle>
<DialogDescription className="hidden" />
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
</div>
)}
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-b-lg bg-slate-50 px-8">
<p className="text-sm text-slate-500">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => setShowView("embed")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<Code2Icon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.embed_survey")}
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BellRing className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.configure_alerts")}
</Link>
<Link
href={`/environments/${environmentId}/integrations`}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.setup_integrations")}
</Link>
<button
type="button"
onClick={() => setShowView("panel")}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-sm text-slate-500 hover:border-slate-200 md:p-8">
<UsersRound className="h-6 w-6 text-slate-700" />
{t("environments.surveys.summary.send_to_panel")}
<Badge
size="tiny"
type="success"
className="absolute right-3 top-3"
text={t("common.new")}
/>
</button>
</div>
</div>
</div>
) : showView === "embed" ? (
<EmbedView
handleInitialPageButton={handleInitialPageButton}
tabs={survey.type === "link" ? tabs : [tabs[3]]}
disableBack={false}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
/>
) : showView === "panel" ? (
<PanelInfoView handleInitialPageButton={handleInitialPageButton} disableBack={false} />
) : null}
</DialogContent>
</Dialog>
);
};

View File

@@ -20,22 +20,9 @@ vi.mock("@/modules/ui/components/button", () => ({
}),
}));
// Mock Dialog
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: vi.fn(({ children, open, onOpenChange }) =>
open ? (
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null
),
DialogContent: vi.fn(({ children, ...props }) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
)),
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
// Mock Modal
vi.mock("@/modules/ui/components/modal", () => ({
Modal: vi.fn(({ children, open }) => (open ? <div data-testid="modal">{children}</div> : null)),
}));
// Mock useTranslate
@@ -133,7 +120,7 @@ describe("ShareSurveyResults", () => {
test("does not render content when modal is closed (open is false)", () => {
render(<ShareSurveyResults {...defaultProps} open={false} />);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
expect(
screen.queryByText("environments.surveys.summary.survey_results_are_public")

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
import { Clipboard } from "lucide-react";
@@ -26,72 +26,70 @@ export const ShareSurveyResults = ({
}: ShareEmbedSurveyProps) => {
const { t } = useTranslate();
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogBody>
{showPublishModal && surveyUrl ? (
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="text-primary h-20 w-20" />
<div>
<p className="text-primary text-lg font-medium">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
<Modal open={open} setOpen={setOpen} size="lg">
{showPublishModal && surveyUrl ? (
<div className="flex flex-col rounded-2xl bg-white px-12 py-6">
<div className="flex flex-col items-center gap-y-6 text-center">
<CheckCircle2Icon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.survey_results_are_public")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
</p>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
<div className="flex gap-2">
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
<span>{surveyUrl}</span>
</div>
<Button
variant="secondary"
size="sm"
title="Copy survey link to clipboard"
aria-label="Copy survey link to clipboard"
className="hover:cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(surveyUrl);
toast.success(t("common.link_copied"));
}}>
<Clipboard />
</Button>
</div>
)}
</DialogBody>
</DialogContent>
</Dialog>
<div className="flex gap-2">
<Button
type="submit"
variant="secondary"
className="text-center"
onClick={() => handleUnpublish()}>
{t("environments.surveys.summary.unpublish_from_web")}
</Button>
<Button className="text-center" asChild>
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
{t("environments.surveys.summary.view_site")}
</Link>
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-col rounded-2xl bg-white p-8">
<div className="flex flex-col items-center gap-y-6 text-center">
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
<div>
<p className="text-lg font-medium text-slate-600">
{t("environments.surveys.summary.publish_to_web_warning")}
</p>
<p className="text-balanced mt-2 text-sm text-slate-500">
{t("environments.surveys.summary.publish_to_web_warning_description")}
</p>
</div>
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
{t("environments.surveys.summary.publish_to_web")}
</Button>
</div>
</div>
)}
</Modal>
);
};

View File

@@ -1,5 +1,6 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
@@ -117,13 +118,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
<Button variant="secondary" className="h-6 w-6">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</div>
</Button>
)}
</div>
</div>

View File

@@ -1,27 +1,22 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
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 { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, ListRestart, SquarePenIcon } from "lucide-react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { resetSurveyAction } from "../actions";
interface SurveyAnalysisCTAProps {
survey: TSurvey;
@@ -30,15 +25,13 @@ interface SurveyAnalysisCTAProps {
user: TUser;
publicDomain: string;
responseCount: number;
displayCount: number;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
interface ModalState {
start: boolean;
share: boolean;
embed: boolean;
panel: boolean;
dropdown: boolean;
}
export const SurveyAnalysisCTA = ({
@@ -48,48 +41,40 @@ export const SurveyAnalysisCTA = ({
user,
publicDomain,
responseCount,
displayCount,
segments,
isContactsEnabled,
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
start: searchParams.get("share") === "true",
share: false,
});
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const { organizationId, project } = useEnvironment();
const { refreshSingleUseId } = useSingleUseId(survey);
const [modalState, setModalState] = useState<ModalState>({
share: searchParams.get("share") === "true",
embed: false,
panel: false,
dropdown: false,
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
useEffect(() => {
setModalState((prev) => ({
...prev,
start: searchParams.get("share") === "true",
share: searchParams.get("share") === "true",
}));
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
if (open) {
params.set("share", "true");
router.push(`${pathname}?${params.toString()}`);
} else if (!open && currentShareParam) {
} else {
params.delete("share");
router.push(`${pathname}?${params.toString()}`);
}
setModalState((prev) => ({ ...prev, start: open }));
router.push(`${pathname}?${params.toString()}`);
setModalState((prev) => ({ ...prev, share: open }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
@@ -110,45 +95,26 @@ export const SurveyAnalysisCTA = ({
setLoading(false);
};
const getPreviewUrl = async () => {
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
surveyUrl.searchParams.set("preview", "true");
return surveyUrl.toString();
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
};
const handleModalState = (modalView: keyof Omit<ModalState, "dropdown">) => {
return (open: boolean | ((prevState: boolean) => boolean)) => {
const newValue = typeof open === "function" ? open(modalState[modalView]) : open;
setModalState((prev) => ({ ...prev, [modalView]: newValue }));
};
};
const shareEmbedViews = [
{ key: "share", modalView: "start" as const, setOpen: handleShareModalToggle },
{ key: "embed", modalView: "embed" as const, setOpen: handleModalState("embed") },
{ key: "panel", modalView: "panel" as const, setOpen: handleModalState("panel") },
];
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
const handleResetSurvey = async () => {
setIsResetting(true);
const result = await resetSurveyAction({
surveyId: survey.id,
organizationId: organizationId,
projectId: project.id,
});
if (result?.data) {
toast.success(
t("environments.surveys.summary.survey_reset_successfully", {
responseCount: result.data.deletedResponsesCount,
displayCount: result.data.deletedDisplaysCount,
})
);
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
setIsResetting(false);
setIsResetModalOpen(false);
};
const iconActions = [
{
icon: BellRing,
@@ -159,18 +125,9 @@ export const SurveyAnalysisCTA = ({
{
icon: Eye,
tooltip: t("common.preview"),
onClick: async () => {
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{
icon: ListRestart,
tooltip: t("environments.surveys.summary.reset_survey"),
onClick: () => setIsResetModalOpen(true),
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
},
{
icon: SquarePenIcon,
tooltip: t("common.edit"),
@@ -200,31 +157,29 @@ export const SurveyAnalysisCTA = ({
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, share: true }));
setModalState((prev) => ({ ...prev, embed: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<ShareSurveyModal
survey={survey}
publicDomain={publicDomain}
open={modalState.start || modalState.share}
setOpen={(open) => {
if (!open) {
handleShareModalToggle(false);
setModalState((prev) => ({ ...prev, share: false }));
}
}}
user={user}
modalView={modalState.start ? "start" : "share"}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
<>
{shareEmbedViews.map(({ key, modalView, setOpen }) => (
<ShareEmbedSurvey
key={key}
survey={survey}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}
modalView={modalView}
/>
))}
<SuccessMessage environment={environment} survey={survey} />
</>
)}
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog
@@ -239,17 +194,6 @@ export const SurveyAnalysisCTA = ({
secondaryButtonText={t("common.edit")}
/>
)}
<ConfirmationModal
open={isResetModalOpen}
setOpen={setIsResetModalOpen}
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
text={t("environments.surveys.summary.reset_survey_warning")}
buttonText={t("environments.surveys.summary.reset_survey")}
onConfirm={handleResetSurvey}
buttonVariant="destructive"
buttonLoading={isResetting}
/>
</div>
);
};

View File

@@ -1,473 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { ShareSurveyModal } from "./share-survey-modal";
// Mock getPublicDomain - must be first to prevent server-side env access
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("https://example.com"),
}));
// Mock env to prevent server-side env access
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
NODE_ENV: "test",
E2E_TESTING: "0",
ENCRYPTION_KEY: "test-encryption-key-32-characters",
WEBAPP_URL: "https://example.com",
CRON_SECRET: "test-cron-secret",
PUBLIC_URL: "https://example.com",
VERCEL_URL: "",
},
}));
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"environments.surveys.summary.single_use_links": "Single-use links",
"environments.surveys.summary.share_the_link": "Share the link",
"environments.surveys.summary.qr_code": "QR Code",
"environments.surveys.summary.personal_links": "Personal links",
"environments.surveys.summary.embed_in_an_email": "Embed in email",
"environments.surveys.summary.embed_on_website": "Embed on website",
"environments.surveys.summary.dynamic_popup": "Dynamic popup",
"environments.surveys.summary.in_app.title": "In-app survey",
"environments.surveys.summary.in_app.description": "Display survey in your app",
"environments.surveys.share.anonymous_links.nav_title": "Share the link",
"environments.surveys.share.single_use_links.nav_title": "Single-use links",
"environments.surveys.share.personal_links.nav_title": "Personal links",
"environments.surveys.share.embed_on_website.nav_title": "Embed on website",
"environments.surveys.share.send_email.nav_title": "Embed in email",
"environments.surveys.share.social_media.title": "Social media",
"environments.surveys.share.dynamic_popup.nav_title": "Dynamic popup",
};
return translations[key] || key;
},
}),
}));
// Mock analysis utils
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://example.com/s/test-survey-id"),
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
log: vi.fn(),
},
}));
// Mock dialog components
vi.mock("@/modules/ui/components/dialog", () => ({
Dialog: ({ open, onOpenChange, children }: any) => (
<div data-testid="dialog" data-open={open} onClick={() => onOpenChange(false)}>
{children}
</div>
),
DialogContent: ({ children, width }: any) => (
<div data-testid="dialog-content" data-width={width}>
{children}
</div>
),
DialogTitle: ({ children }: any) => <div data-testid="dialog-title">{children}</div>,
}));
// Mock VisuallyHidden
vi.mock("@radix-ui/react-visually-hidden", () => ({
VisuallyHidden: ({ asChild, children }: any) => (
<div data-testid="visually-hidden">{asChild ? children : <span>{children}</span>}</div>
),
}));
// Mock child components
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab",
() => ({
AppTab: () => <div data-testid="app-tab">App Tab Content</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
() => ({
TabContainer: ({ title, description, children }: any) => (
<div data-testid="tab-container">
<h3>{title}</h3>
<p>{description}</p>
{children}
</div>
),
})
);
vi.mock("./shareEmbedModal/share-view", () => ({
ShareView: ({ tabs, activeId, setActiveId }: any) => (
<div data-testid="share-view" data-active-id={activeId}>
<h3>Share View</h3>
<div data-testid="share-view-data">
<div>Active Tab: {activeId}</div>
</div>
<div data-testid="tabs">
{tabs.map((tab: any) => (
<button key={tab.id} onClick={() => setActiveId(tab.id)} data-testid={`tab-${tab.id}`}>
{tab.label}
</button>
))}
</div>
</div>
),
}));
vi.mock("./shareEmbedModal/success-view", () => ({
SuccessView: ({
survey,
surveyUrl,
publicDomain,
user,
tabs,
handleViewChange,
handleEmbedViewWithTab,
}: any) => (
<div data-testid="success-view">
<h3>Success View</h3>
<div data-testid="success-view-data">
<div>Survey: {survey?.id}</div>
<div>URL: {surveyUrl}</div>
<div>Domain: {publicDomain}</div>
<div>User: {user?.id}</div>
</div>
<div data-testid="success-tabs">
{tabs.map((tab: any) => {
// Handle single-use links case
let displayLabel = tab.label;
if (tab.id === "anon-links" && survey?.singleUse?.enabled) {
displayLabel = "Single-use links";
}
return (
<button
key={tab.id}
onClick={() => handleEmbedViewWithTab(tab.id)}
data-testid={`success-tab-${tab.id}`}>
{displayLabel}
</button>
);
})}
</div>
<button onClick={() => handleViewChange("share")} data-testid="go-to-share-view">
Go to Share View
</button>
</div>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", async (importOriginal) => {
const actual = (await importOriginal()) as any;
return {
...actual,
Code2Icon: () => <svg data-testid="code2-icon" />,
LinkIcon: () => <svg data-testid="link-icon" />,
MailIcon: () => <svg data-testid="mail-icon" />,
QrCodeIcon: () => <svg data-testid="qrcode-icon" />,
SmartphoneIcon: () => <svg data-testid="smartphone-icon" />,
SquareStack: () => <svg data-testid="square-stack-icon" />,
UserIcon: () => <svg data-testid="user-icon" />,
};
});
// Mock data
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "link",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false, timeToFinish: false, showResponseCount: false },
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
createdBy: null,
variables: [],
followUps: [],
runOnDate: null,
closeOnDate: null,
styling: null,
pin: null,
recaptcha: null,
isSingleResponsePerEmailEnabled: false,
isBackButtonHidden: false,
resultShareKey: null,
};
const mockAppSurvey: TSurvey = {
...mockSurvey,
type: "app",
};
const mockUser: TUser = {
id: "test-user-id",
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "other",
objective: "other",
locale: "en-US",
lastLoginAt: new Date(),
isActive: true,
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
};
const mockSegments: TSegment[] = [];
const mockSetOpen = vi.fn();
const defaultProps = {
survey: mockSurvey,
publicDomain: "https://example.com",
open: true,
modalView: "start" as const,
setOpen: mockSetOpen,
user: mockUser,
segments: mockSegments,
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("ShareSurveyModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
test("renders dialog when open is true", () => {
render(<ShareSurveyModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
});
test("renders success view when modalView is start", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
expect(screen.getByText("Success View")).toBeInTheDocument();
});
test("renders share view when modalView is share and survey is link type", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.getByText("Share View")).toBeInTheDocument();
});
test("renders app tab when survey is app type and modalView is share", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.getByText("In-app survey")).toBeInTheDocument();
expect(screen.getByText("Display survey in your app")).toBeInTheDocument();
});
test("renders success view when survey is app type and modalView is start", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="start" />);
expect(screen.getByTestId("success-view")).toBeInTheDocument();
expect(screen.queryByTestId("tab-container")).not.toBeInTheDocument();
});
test("sets correct width for dialog content based on survey type", () => {
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} />);
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "wide");
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} />);
expect(screen.getByTestId("dialog-content")).toHaveAttribute("data-width", "default");
});
test("generates correct tabs for link survey", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Share the link");
expect(screen.getByTestId("success-tab-qr-code")).toHaveTextContent("QR Code");
expect(screen.getByTestId("success-tab-personal-links")).toHaveTextContent("Personal links");
expect(screen.getByTestId("success-tab-email")).toHaveTextContent("Embed in email");
expect(screen.getByTestId("success-tab-website-embed")).toHaveTextContent("Embed on website");
expect(screen.getByTestId("success-tab-dynamic-popup")).toHaveTextContent("Dynamic popup");
});
test("shows single-use links label when singleUse is enabled", () => {
const singleUseSurvey = { ...mockSurvey, singleUse: { enabled: true, isEncrypted: false } };
render(<ShareSurveyModal {...defaultProps} survey={singleUseSurvey} modalView="start" />);
expect(screen.getByTestId("success-tab-anon-links")).toHaveTextContent("Single-use links");
});
test("calls setOpen when dialog is closed", async () => {
const user = userEvent.setup();
render(<ShareSurveyModal {...defaultProps} />);
await user.click(screen.getByTestId("dialog"));
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
test("fetches survey URL on mount", async () => {
const { getSurveyUrl } = await import("@/modules/analysis/utils");
render(<ShareSurveyModal {...defaultProps} />);
await waitFor(() => {
expect(getSurveyUrl).toHaveBeenCalledWith(mockSurvey, "https://example.com", "default");
});
});
test("handles getSurveyUrl failure gracefully", async () => {
const { getSurveyUrl } = await import("@/modules/analysis/utils");
vi.mocked(getSurveyUrl).mockRejectedValue(new Error("Failed to fetch"));
// Render and verify it doesn't crash, even if nothing renders due to the error
expect(() => {
render(<ShareSurveyModal {...defaultProps} />);
}).not.toThrow();
});
test("renders ShareView with correct active tab", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
const shareViewData = screen.getByTestId("share-view-data");
expect(shareViewData).toHaveTextContent("Active Tab: anon-links");
});
test("passes correct props to SuccessView", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
const successViewData = screen.getByTestId("success-view-data");
expect(successViewData).toHaveTextContent("Survey: test-survey-id");
expect(successViewData).toHaveTextContent("Domain: https://example.com");
expect(successViewData).toHaveTextContent("User: test-user-id");
});
test("resets to start view when modal is closed and reopened", async () => {
const user = userEvent.setup();
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={false} />);
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={true} />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
test("sets correct active tab for link survey", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
});
test("renders tab container for app survey in share mode", () => {
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
test("renders with contacts disabled", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" isContactsEnabled={false} />);
// Just verify the ShareView renders correctly regardless of isContactsEnabled prop
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
});
test("renders with formbricks cloud enabled", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" isFormbricksCloud={true} />);
// Just verify the ShareView renders correctly regardless of isFormbricksCloud prop
expect(screen.getByTestId("share-view")).toBeInTheDocument();
});
test("correctly handles direct navigation to share view", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
expect(screen.queryByTestId("success-view")).not.toBeInTheDocument();
});
test("handler functions are passed to child components", () => {
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
// Verify SuccessView receives the handler functions by checking buttons exist
expect(screen.getByTestId("go-to-share-view")).toBeInTheDocument();
expect(screen.getByTestId("success-tab-anon-links")).toBeInTheDocument();
expect(screen.getByTestId("success-tab-qr-code")).toBeInTheDocument();
});
test("tab switching functionality is available in ShareView", () => {
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
// Verify ShareView has tab switching buttons
expect(screen.getByTestId("tab-anon-links")).toBeInTheDocument();
expect(screen.getByTestId("tab-qr-code")).toBeInTheDocument();
expect(screen.getByTestId("tab-personal-links")).toBeInTheDocument();
});
test("renders different content based on survey type", () => {
// Link survey renders ShareView
const { rerender } = render(<ShareSurveyModal {...defaultProps} survey={mockSurvey} modalView="share" />);
expect(screen.getByTestId("share-view")).toBeInTheDocument();
// App survey renders TabContainer with AppTab
rerender(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
});
});

View File

@@ -1,228 +0,0 @@
"use client";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
import { ShareView } from "./shareEmbedModal/share-view";
import { SuccessView } from "./shareEmbedModal/success-view";
type ModalView = "start" | "share";
interface ShareSurveyModalProps {
survey: TSurvey;
publicDomain: string;
open: boolean;
modalView: ModalView;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareSurveyModal = ({
survey,
publicDomain,
open,
modalView,
setOpen,
user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: ShareSurveyModalProps) => {
const environmentId = survey.environmentId;
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
const [showView, setShowView] = useState<ModalView>(modalView);
const { email } = user;
const { t } = useTranslate();
const linkTabs: {
id: ShareViewType;
label: string;
icon: React.ElementType;
title: string;
description: string;
componentType: React.ComponentType<any>;
componentProps: any;
}[] = useMemo(
() => [
{
id: ShareViewType.ANON_LINKS,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
componentProps: {
survey,
publicDomain,
setSurveyUrl,
locale: user.locale,
surveyUrl,
},
},
{
id: ShareViewType.PERSONAL_LINKS,
label: t("environments.surveys.share.personal_links.nav_title"),
icon: UserIcon,
title: t("environments.surveys.share.personal_links.nav_title"),
description: t("environments.surveys.share.personal_links.description"),
componentType: PersonalLinksTab,
componentProps: {
environmentId,
surveyId: survey.id,
segments,
isContactsEnabled,
isFormbricksCloud,
},
},
{
id: ShareViewType.WEBSITE_EMBED,
label: t("environments.surveys.share.embed_on_website.nav_title"),
icon: Code2Icon,
title: t("environments.surveys.share.embed_on_website.nav_title"),
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.EMAIL,
label: t("environments.surveys.share.send_email.nav_title"),
icon: MailIcon,
title: t("environments.surveys.share.send_email.nav_title"),
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
},
{
id: ShareViewType.SOCIAL_MEDIA,
label: t("environments.surveys.share.social_media.title"),
icon: Share2Icon,
title: t("environments.surveys.share.social_media.title"),
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
},
{
id: ShareViewType.QR_CODE,
label: t("environments.surveys.summary.qr_code"),
icon: QrCodeIcon,
title: t("environments.surveys.summary.qr_code"),
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: t("environments.surveys.share.dynamic_popup.nav_title"),
icon: SquareStack,
title: t("environments.surveys.share.dynamic_popup.nav_title"),
description: t("environments.surveys.share.dynamic_popup.description"),
componentType: DynamicPopupTab,
componentProps: { environmentId, surveyId: survey.id },
},
],
[
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
]
);
const [activeId, setActiveId] = useState(
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
);
useEffect(() => {
if (open) {
setShowView(modalView);
}
}, [open, modalView]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setShowView("start");
setActiveId(ShareViewType.ANON_LINKS);
}
};
const handleViewChange = (view: ModalView) => {
setShowView(view);
};
const handleEmbedViewWithTab = (tabId: ShareViewType) => {
setShowView("share");
setActiveId(tabId);
};
const renderContent = () => {
if (showView === "start") {
return (
<SuccessView
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
user={user}
tabs={linkTabs}
handleViewChange={handleViewChange}
handleEmbedViewWithTab={handleEmbedViewWithTab}
/>
);
}
if (survey.type === "link") {
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
}
return (
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
<TabContainer
title={t("environments.surveys.summary.in_app.title")}
description={t("environments.surveys.summary.in_app.description")}>
<AppTab />
</TabContainer>
</div>
);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<VisuallyHidden asChild>
<DialogTitle />
</VisuallyHidden>
<DialogContent
className="w-full bg-white p-0 lg:h-[700px]"
width={survey.type === "link" ? "wide" : "default"}
aria-describedby={undefined}
unconstrained>
{renderContent()}
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AppTab } from "./AppTab";
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: {
options: Array<{ value: string; label: string }>;
handleOptionChange: (value: string) => void;
}) => (
<div data-testid="options-switch">
{props.options.map((option) => (
<button
key={option.value}
data-testid={`option-${option.value}`}
onClick={() => props.handleOptionChange(option.value)}>
{option.label}
</button>
))}
</div>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab",
() => ({
MobileAppTab: () => <div data-testid="mobile-app-tab">MobileAppTab</div>,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab",
() => ({
WebAppTab: () => <div data-testid="web-app-tab">WebAppTab</div>,
})
);
describe("AppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly by default with WebAppTab visible", () => {
render(<AppTab />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(screen.getByTestId("option-webapp")).toBeInTheDocument();
expect(screen.getByTestId("option-mobile")).toBeInTheDocument();
expect(screen.getByTestId("web-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("mobile-app-tab")).not.toBeInTheDocument();
});
test("switches to MobileAppTab when mobile option is selected", async () => {
const user = userEvent.setup();
render(<AppTab />);
const mobileOptionButton = screen.getByTestId("option-mobile");
await user.click(mobileOptionButton);
expect(screen.getByTestId("mobile-app-tab")).toBeInTheDocument();
expect(screen.queryByTestId("web-app-tab")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,27 @@
"use client";
import { MobileAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/MobileAppTab";
import { WebAppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/WebAppTab";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react";
import { useState } from "react";
export const AppTab = () => {
const { t } = useTranslate();
const [selectedTab, setSelectedTab] = useState("webapp");
return (
<div className="flex h-full grow flex-col">
<OptionsSwitch
options={[
{ value: "webapp", label: t("environments.surveys.summary.web_app") },
{ value: "mobile", label: t("environments.surveys.summary.mobile_app") },
]}
currentOption={selectedTab}
handleOptionChange={(value) => setSelectedTab(value)}
/>
<div className="mt-4">{selectedTab === "webapp" ? <WebAppTab /> : <MobileAppTab />}</div>
</div>
);
};

View File

@@ -5,7 +5,7 @@ import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
import { EmailTab } from "./email-tab";
import { EmailTab } from "./EmailTab";
// Mock actions
vi.mock("../../actions", () => ({
@@ -20,23 +20,15 @@ vi.mock("@/lib/utils/helper", () => ({
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} aria-label={ariaLabel} {...props}>
Button: ({ children, onClick, variant, title, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({
children,
language,
showCopyToClipboard,
}: {
children: React.ReactNode;
language: string;
showCopyToClipboard?: boolean;
}) => (
<div data-testid="code-block" data-language={language} data-show-copy={showCopyToClipboard}>
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
<div data-testid="code-block" data-language={language}>
{children}
</div>
),
@@ -49,9 +41,7 @@ vi.mock("@/modules/ui/components/loading-spinner", () => ({
vi.mock("lucide-react", () => ({
Code2Icon: () => <div data-testid="code2-icon" />,
CopyIcon: () => <div data-testid="copy-icon" />,
EyeIcon: () => <div data-testid="eye-icon" />,
MailIcon: () => <div data-testid="mail-icon" />,
SendIcon: () => <div data-testid="send-icon" />,
}));
// Mock navigator.clipboard
@@ -84,42 +74,22 @@ describe("EmailTab", () => {
expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalledWith({ surveyId });
// Buttons
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
// Note: code2-icon is only visible in the embed code tab, not in initial render
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
// Email preview section
await waitFor(() => {
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
});
expect(
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_subject_label") || false
);
}).length
).toBeGreaterThan(0);
expect(
screen.getAllByText((content, element) => {
return (
element?.textContent?.includes(
"environments.surveys.share.send_email.formbricks_email_survey_preview"
) || false
);
}).length
).toBeGreaterThan(0);
screen.getByText("Subject : environments.surveys.summary.formbricks_email_survey_preview")
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed)
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -129,47 +99,32 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.embed_code_tab",
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);
// Embed code view
expect(screen.getByRole("button", { name: "Embed survey in your website" })).toBeInTheDocument(); // Updated name
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" })
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" }) // Updated name for hide button
).toBeInTheDocument();
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
// The email_to_label should not be visible in embed code view
expect(
screen.queryByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
})
).not.toBeInTheDocument();
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
// Toggle back to preview
const previewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.email_preview_tab",
// Toggle back
const hideEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
});
await userEvent.click(previewButton);
await userEvent.click(hideEmbedButton);
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.embed_code_tab" })
).toBeInTheDocument();
await waitFor(() => {
const emailToElements = screen.getAllByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
});
expect(emailToElements.length).toBeGreaterThan(0);
});
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -178,19 +133,16 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.embed_code_tab",
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);
const copyCodeButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.copy_embed_code",
});
// Ensure this line queries by the correct aria-label
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
await userEvent.click(copyCodeButton);
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.share.send_email.embed_code_copied_to_clipboard"
);
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
});
test("sends preview email successfully", async () => {
@@ -198,13 +150,11 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
});
test("handles send preview email failure (server error)", async () => {
@@ -213,9 +163,7 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -228,9 +176,7 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -244,9 +190,7 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -264,19 +208,14 @@ describe("EmailTab", () => {
test("renders default email if email prop is not provided", async () => {
render(<EmailTab surveyId={surveyId} email="" />);
await waitFor(() => {
expect(
screen.getByText((content, element) => {
return (
element?.textContent === "environments.surveys.share.send_email.email_to_label : user@mail.com"
);
})
).toBeInTheDocument();
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
});
});
test("emailHtml memo removes various ?preview=true patterns", async () => {
const htmlWithVariants =
"<p>Test1 ?preview=true</p><p>Test2 ?preview=true&amp;next</p><p>Test3 ?preview=true&;next</p>";
// Ensure this line matches the "Received" output from your test error
const expectedCleanHtml = "<p>Test1 </p><p>Test2 ?next</p><p>Test3 ?next</p>";
vi.mocked(getEmailHtmlAction).mockResolvedValue({ data: htmlWithVariants });
@@ -284,7 +223,7 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.embed_code_tab",
name: "environments.surveys.summary.view_embed_code_for_email",
});
await userEvent.click(viewEmbedButton);

View File

@@ -0,0 +1,133 @@
"use client";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, CopyIcon, MailIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { AuthenticationError } from "@formbricks/types/errors";
import { getEmailHtmlAction, sendEmbedSurveyPreviewEmailAction } from "../../actions";
interface EmailTabProps {
surveyId: string;
email: string;
}
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
const [showEmbed, setShowEmbed] = useState(false);
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
const { t } = useTranslate();
const emailHtml = useMemo(() => {
if (!emailHtmlPreview) return "";
return emailHtmlPreview
.replaceAll("?preview=true&amp;", "?")
.replaceAll("?preview=true&;", "?")
.replaceAll("?preview=true", "");
}, [emailHtmlPreview]);
useEffect(() => {
const getData = async () => {
const emailHtml = await getEmailHtmlAction({ surveyId });
setEmailHtmlPreview(emailHtml?.data || "");
};
getData();
}, [surveyId]);
const sendPreviewEmail = async () => {
try {
const val = await sendEmbedSurveyPreviewEmailAction({ surveyId });
if (val?.data) {
toast.success(t("environments.surveys.summary.email_sent"));
} else {
const errorMessage = getFormattedErrorMessage(val);
toast.error(errorMessage);
}
} catch (err) {
if (err instanceof AuthenticationError) {
toast.error(t("common.not_authenticated"));
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<div className="flex flex-col gap-5">
<div className="flex items-center justify-end gap-4">
{showEmbed ? (
<Button
variant="secondary"
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
navigator.clipboard.writeText(emailHtml);
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
) : (
<>
<Button
variant="secondary"
title="send preview email"
aria-label="send preview email"
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.summary.send_preview")}
<MailIcon />
</Button>
</>
)}
<Button
title={t("environments.surveys.summary.view_embed_code_for_email")}
aria-label={t("environments.surveys.summary.view_embed_code_for_email")}
onClick={() => {
setShowEmbed(!showEmbed);
}}
className="shrink-0">
{showEmbed
? t("environments.surveys.summary.hide_embed_code")
: t("environments.surveys.summary.view_embed_code")}
<Code2Icon />
</Button>
</div>
{showEmbed ? (
<div className="prose prose-slate -mt-4 max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll"
language="html"
showCopyToClipboard={false}>
{emailHtml}
</CodeBlock>
</div>
) : (
<div className="mb-12 grow overflow-y-auto rounded-xl border border-slate-200 bg-white p-4">
<div className="mb-6 flex gap-2">
<div className="h-3 w-3 rounded-full bg-red-500"></div>
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">To : {email || "user@mail.com"}</div>
<div className="border-b border-slate-200 pb-2 text-sm">
Subject : {t("environments.surveys.summary.formbricks_email_survey_preview")}
</div>
<div className="p-4">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,154 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { EmbedView } from "./EmbedView";
// Mock child components
vi.mock("./AppTab", () => ({
AppTab: () => <div data-testid="app-tab">AppTab Content</div>,
}));
vi.mock("./EmailTab", () => ({
EmailTab: (props: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {props.surveyId} with {props.email}
</div>
),
}));
vi.mock("./LinkTab", () => ({
LinkTab: (props: { survey: any; surveyUrl: string }) => (
<div data-testid="link-tab">
LinkTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./WebsiteTab", () => ({
WebsiteTab: (props: { surveyUrl: string; environmentId: string }) => (
<div data-testid="website-tab">
WebsiteTab Content for {props.surveyUrl} in {props.environmentId}
</div>
),
}));
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
MailIcon: () => <div data-testid="mail-icon">MailIcon</div>,
LinkIcon: () => <div data-testid="link-icon">LinkIcon</div>,
GlobeIcon: () => <div data-testid="globe-icon">GlobeIcon</div>,
SmartphoneIcon: () => <div data-testid="smartphone-icon">SmartphoneIcon</div>,
}));
const mockTabs = [
{ id: "email", label: "Email", icon: () => <div data-testid="email-tab-icon" /> },
{ id: "webpage", label: "Web Page", icon: () => <div data-testid="webpage-tab-icon" /> },
{ id: "link", label: "Link", icon: () => <div data-testid="link-tab-icon" /> },
{ id: "app", label: "App", icon: () => <div data-testid="app-tab-icon" /> },
];
const mockSurveyLink = { id: "survey1", type: "link" };
const mockSurveyWeb = { id: "survey2", type: "web" };
const defaultProps = {
handleInitialPageButton: vi.fn(),
tabs: mockTabs,
activeId: "email",
setActiveId: vi.fn(),
environmentId: "env1",
survey: mockSurveyLink,
email: "test@example.com",
surveyUrl: "http://example.com/survey1",
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
disableBack: false,
};
describe("EmbedView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("does not render back button when disableBack is true", () => {
render(<EmbedView {...defaultProps} disableBack={true} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
});
test("does not render desktop tabs for non-link survey type", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyWeb} />);
// Desktop tabs container should not be present or not have lg:flex if it's a common parent
const desktopTabsButtons = screen.queryAllByRole("button", { name: /Email|Web Page|Link|App/i });
// Check if any of these buttons are part of a container that is only visible on large screens
const desktopTabContainer = desktopTabsButtons[0]?.closest("div.lg\\:flex");
expect(desktopTabContainer).toBeNull();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0]; // First one is desktop
await userEvent.click(webpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("renders EmailTab when activeId is 'email'", () => {
render(<EmbedView {...defaultProps} activeId="email" />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(
screen.getByText(`EmailTab Content for ${defaultProps.survey.id} with ${defaultProps.email}`)
).toBeInTheDocument();
});
test("renders WebsiteTab when activeId is 'webpage'", () => {
render(<EmbedView {...defaultProps} activeId="webpage" />);
expect(screen.getByTestId("website-tab")).toBeInTheDocument();
expect(
screen.getByText(`WebsiteTab Content for ${defaultProps.surveyUrl} in ${defaultProps.environmentId}`)
).toBeInTheDocument();
});
test("renders LinkTab when activeId is 'link'", () => {
render(<EmbedView {...defaultProps} activeId="link" />);
expect(screen.getByTestId("link-tab")).toBeInTheDocument();
expect(
screen.getByText(`LinkTab Content for ${defaultProps.survey.id} at ${defaultProps.surveyUrl}`)
).toBeInTheDocument();
});
test("renders AppTab when activeId is 'app'", () => {
render(<EmbedView {...defaultProps} activeId="app" />);
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
// Get the responsive tab button (second instance of the button with this name)
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
await userEvent.click(responsiveWebpageTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith("webpage");
});
test("applies active styles to the active tab (desktop)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const emailTabButton = screen.getAllByRole("button", { name: "Email" })[0];
expect(emailTabButton).toHaveClass("border-slate-200 bg-slate-100 font-semibold text-slate-900");
const webpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[0];
expect(webpageTabButton).toHaveClass("border-transparent text-slate-500 hover:text-slate-700");
});
test("applies active styles to the active tab (responsive)", () => {
render(<EmbedView {...defaultProps} survey={mockSurveyLink} activeId="email" />);
const responsiveEmailTabButton = screen.getAllByRole("button", { name: "Email" })[1];
expect(responsiveEmailTabButton).toHaveClass("bg-white text-slate-900 shadow-sm");
const responsiveWebpageTabButton = screen.getAllByRole("button", { name: "Web Page" })[1];
expect(responsiveWebpageTabButton).toHaveClass("border-transparent text-slate-700 hover:text-slate-900");
});
});

View File

@@ -0,0 +1,113 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab";
interface EmbedViewProps {
handleInitialPageButton: () => void;
tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string;
disableBack: boolean;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
}
export const EmbedView = ({
handleInitialPageButton,
tabs,
disableBack,
activeId,
setActiveId,
environmentId,
survey,
email,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
}: EmbedViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden">
{!disableBack && (
<div className="border-b border-slate-200 py-2 pl-2">
<Button variant="ghost" className="focus:ring-0" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-4">
{survey.type === "link" && (
<div className={cn("col-span-1 hidden flex-col gap-3 border-r border-slate-200 p-4 lg:flex")}>
{tabs.map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
autoFocus={tab.id === activeId}
className={cn(
"flex justify-start rounded-md border px-4 py-2 text-slate-600",
// "focus:ring-0 focus:ring-offset-0", // enable these classes to remove the focus rings on buttons
tab.id === activeId
? "border-slate-200 bg-slate-100 font-semibold text-slate-900"
: "border-transparent text-slate-500 hover:text-slate-700"
)}
aria-current={tab.id === activeId ? "page" : undefined}>
<tab.icon />
{tab.label}
</Button>
))}
</div>
)}
<div
className={`col-span-4 h-full overflow-y-auto bg-slate-50 px-4 py-6 ${survey.type === "link" ? "lg:col-span-3" : ""} lg:p-6`}>
{activeId === "email" ? (
<EmailTab surveyId={survey.id} email={email} />
) : activeId === "webpage" ? (
<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />
) : activeId === "link" ? (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
) : activeId === "app" ? (
<AppTab />
) : null}
<div className="mt-2 rounded-md p-3 text-center lg:hidden">
{tabs.slice(0, 2).map((tab) => (
<Button
variant="ghost"
key={tab.id}
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
{tab.label}
</Button>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,155 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { LinkTab } from "./LinkTab";
// Mock ShareSurveyLink
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: vi.fn(({ survey, surveyUrl, publicDomain, locale }) => (
<div data-testid="share-survey-link">
Mocked ShareSurveyLink
<span data-testid="survey-id">{survey.id}</span>
<span data-testid="survey-url">{surveyUrl}</span>
<span data-testid="public-domain">{publicDomain}</span>
<span data-testid="locale">{locale}</span>
</div>
)),
}));
// Mock useTranslate
const mockTranslate = vi.fn((key) => key);
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: mockTranslate,
}),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
const mockSurvey: TSurvey = {
id: "survey1",
name: "Test Survey",
type: "link",
status: "inProgress",
questions: [],
thankYouCard: { enabled: false },
endings: [],
autoClose: null,
triggers: [],
languages: [],
styling: null,
} as unknown as TSurvey;
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockPublicDomain = "https://app.formbricks.com";
const mockSetSurveyUrl = vi.fn();
const mockLocale: TUserLocale = "en-US";
const docsLinksExpected = [
{
titleKey: "environments.surveys.summary.data_prefilling",
descriptionKey: "environments.surveys.summary.data_prefilling_description",
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
titleKey: "environments.surveys.summary.source_tracking",
descriptionKey: "environments.surveys.summary.source_tracking_description",
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
titleKey: "environments.surveys.summary.create_single_use_links",
descriptionKey: "environments.surveys.summary.create_single_use_links_description",
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
describe("LinkTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders the main title", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.share_the_link_to_get_responses")
).toBeInTheDocument();
});
test("renders ShareSurveyLink with correct props", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(screen.getByTestId("share-survey-link")).toBeInTheDocument();
expect(screen.getByTestId("survey-id")).toHaveTextContent(mockSurvey.id);
expect(screen.getByTestId("survey-url")).toHaveTextContent(mockSurveyUrl);
expect(screen.getByTestId("public-domain")).toHaveTextContent(mockPublicDomain);
expect(screen.getByTestId("locale")).toHaveTextContent(mockLocale);
});
test("renders the promotional text for link surveys", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
expect(
screen.getByText("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys 💡")
).toBeInTheDocument();
});
test("renders all documentation links correctly", () => {
render(
<LinkTab
survey={mockSurvey}
surveyUrl={mockSurveyUrl}
publicDomain={mockPublicDomain}
setSurveyUrl={mockSetSurveyUrl}
locale={mockLocale}
/>
);
docsLinksExpected.forEach((doc) => {
const linkElement = screen.getByText(doc.titleKey).closest("a");
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute("href", doc.link);
expect(linkElement).toHaveAttribute("target", "_blank");
expect(screen.getByText(doc.descriptionKey)).toBeInTheDocument();
});
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.data_prefilling_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.source_tracking_description");
expect(mockTranslate).toHaveBeenCalledWith("environments.surveys.summary.create_single_use_links");
expect(mockTranslate).toHaveBeenCalledWith(
"environments.surveys.summary.create_single_use_links_description"
);
});
});

View File

@@ -0,0 +1,72 @@
"use client";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface LinkTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const LinkTab = ({ survey, surveyUrl, publicDomain, setSurveyUrl, locale }: LinkTabProps) => {
const { t } = useTranslate();
const docsLinks = [
{
title: t("environments.surveys.summary.data_prefilling"),
description: t("environments.surveys.summary.data_prefilling_description"),
link: "https://formbricks.com/docs/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.summary.source_tracking"),
description: t("environments.surveys.summary.source_tracking_description"),
link: "https://formbricks.com/docs/link-surveys/source-tracking",
},
{
title: t("environments.surveys.summary.create_single_use_links"),
description: t("environments.surveys.summary.create_single_use_links_description"),
link: "https://formbricks.com/docs/link-surveys/single-use-links",
},
];
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.share_the_link_to_get_responses")}
</p>
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
</div>
<div className="flex flex-wrap justify-between gap-2">
<p className="pt-2 font-semibold text-slate-700">
{t("environments.surveys.summary.you_can_do_a_lot_more_with_links_surveys")} 💡
</p>
<div className="grid grid-cols-2 gap-2">
{docsLinks.map((tip) => (
<Link
key={tip.title}
target="_blank"
href={tip.link}
className="relative w-full rounded-md border border-slate-100 bg-white px-6 py-4 text-sm text-slate-600 hover:bg-slate-50 hover:text-slate-800">
<p className="mb-1 font-semibold">{tip.title}</p>
<p className="text-slate-500 hover:text-slate-700">{tip.description}</p>
</Link>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,69 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { MobileAppTab } from "./MobileAppTab";
// Mock @tolgee/react
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key, // Return the key itself for easy assertion
}),
}));
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, asChild, ...props }: { children: React.ReactNode; asChild?: boolean }) =>
asChild ? <div {...props}>{children}</div> : <button {...props}>{children}</button>,
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target, ...props }: any) => (
<a href={href} target={target} {...props}>
{children}
</a>
),
}));
describe("MobileAppTab", () => {
afterEach(() => {
cleanup();
});
test("renders correctly with title, description, and learn more link", () => {
render(<MobileAppTab />);
// Check for Alert component
expect(screen.getByTestId("alert")).toBeInTheDocument();
// Check for AlertTitle with correct Tolgee key
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.summary.quickstart_mobile_apps");
// Check for AlertDescription with correct Tolgee key
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent(
"environments.surveys.summary.quickstart_mobile_apps_description"
);
// Check for the "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -0,0 +1,25 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
export const MobileAppTab = () => {
const { t } = useTranslate();
return (
<Alert>
<AlertTitle>{t("environments.surveys.summary.quickstart_mobile_apps")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.quickstart_mobile_apps_description")}
<Button asChild className="w-fit" size="sm" variant="link">
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
};

View File

@@ -0,0 +1,108 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PanelInfoView } from "./PanelInfoView";
// Mock next/image
vi.mock("next/image", () => ({
default: ({ src, alt, className }: { src: any; alt: string; className?: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img src={src.src} alt={alt} className={className} />
),
}));
// Mock next/link
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target}>
{children}
</a>
),
}));
// Mock Button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, asChild }: any) => {
if (asChild) {
return <div onClick={onClick}>{children}</div>; // NOSONAR
}
return (
<button onClick={onClick} data-variant={variant}>
{children}
</button>
);
},
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
ArrowLeftIcon: vi.fn(() => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>),
}));
const mockHandleInitialPageButton = vi.fn();
describe("PanelInfoView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with back button and all sections", async () => {
render(<PanelInfoView disableBack={false} handleInitialPageButton={mockHandleInitialPageButton} />);
// Check for back button
const backButton = screen.getByText("common.back");
expect(backButton).toBeInTheDocument();
expect(screen.getByTestId("arrow-left-icon")).toBeInTheDocument();
// Check images
expect(screen.getAllByAltText("Prolific panel selection UI")[0]).toBeInTheDocument();
expect(screen.getAllByAltText("Prolific panel selection UI")[1]).toBeInTheDocument();
// Check text content (Tolgee keys)
expect(screen.getByText("environments.surveys.summary.what_is_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_a_panel_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.when_do_i_need_it_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.what_is_prolific_answer")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_1_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_2_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_3_description")
).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.how_to_create_a_panel_step_4_description")
).toBeInTheDocument();
// Check "Learn more" link
const learnMoreLink = screen.getByRole("link", { name: "common.learn_more" });
expect(learnMoreLink).toBeInTheDocument();
expect(learnMoreLink).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
// Click back button
await userEvent.click(backButton);
expect(mockHandleInitialPageButton).toHaveBeenCalledTimes(1);
});
test("renders correctly without back button when disableBack is true", () => {
render(<PanelInfoView disableBack={true} handleInitialPageButton={mockHandleInitialPageButton} />);
expect(screen.queryByRole("button", { name: "common.back" })).not.toBeInTheDocument();
expect(screen.queryByTestId("arrow-left-icon")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,98 @@
"use client";
import ProlificLogo from "@/images/prolific-logo.webp";
import ProlificUI from "@/images/prolific-screenshot.webp";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
interface PanelInfoViewProps {
disableBack: boolean;
handleInitialPageButton: () => void;
}
export const PanelInfoView = ({ disableBack, handleInitialPageButton }: PanelInfoViewProps) => {
const { t } = useTranslate();
return (
<div className="h-full overflow-hidden text-slate-900">
{!disableBack && (
<div className="border-b border-slate-200 py-2">
<Button variant="ghost" onClick={handleInitialPageButton}>
<ArrowLeftIcon />
{t("common.back")}
</Button>
</div>
)}
<div className="grid h-full grid-cols-2">
<div className="flex flex-col gap-y-6 border-r border-slate-200 p-8">
<Image src={ProlificUI} alt="Prolific panel selection UI" className="rounded-lg shadow-lg" />
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_a_panel")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_a_panel_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.when_do_i_need_it")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.when_do_i_need_it_answer")}</p>
</div>
<div>
<p className="text-md font-semibold">{t("environments.surveys.summary.what_is_prolific")}</p>
<p className="text-slate-600">{t("environments.surveys.summary.what_is_prolific_answer")}</p>
</div>
</div>
<div className="relative flex flex-col gap-y-6 bg-slate-50 p-8">
<Image
src={ProlificLogo}
alt="Prolific panel selection UI"
className="absolute right-8 top-8 w-32"
/>
<div>
<h3 className="text-xl font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel")}
</h3>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_1")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_1_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_2")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_2_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_3")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_3_description")}
</p>
</div>
<div>
<p className="text-md font-semibold">
{t("environments.surveys.summary.how_to_create_a_panel_step_4")}
</p>
<p className="text-slate-600">
{t("environments.surveys.summary.how_to_create_a_panel_step_4_description")}
</p>
</div>
<Button className="justify-center" asChild>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/market-research-panel"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WebAppTab } from "./WebAppTab";
vi.mock("@/modules/ui/components/button/Button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }: { children: React.ReactNode }) => <div data-testid="alert">{children}</div>,
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
}));
// Mock navigator.clipboard.writeText
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/test-survey-id";
const surveyId = "test-survey-id";
describe("WebAppTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly with surveyUrl and surveyId", () => {
render(<WebAppTab />);
expect(screen.getByText("environments.surveys.summary.quickstart_web_apps")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.learn_more" })).toHaveAttribute(
"href",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
);
});
});

View File

@@ -0,0 +1,25 @@
"use client";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
export const WebAppTab = () => {
const { t } = useTranslate();
return (
<Alert>
<AlertTitle>{t("environments.surveys.summary.quickstart_web_apps")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.summary.quickstart_web_apps_description")}
<Button asChild className="w-fit" size="sm" variant="link">
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/quickstart"
target="_blank">
{t("common.learn_more")}
</Link>
</Button>
</AlertDescription>
</Alert>
);
};

View File

@@ -0,0 +1,254 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { WebsiteTab } from "./WebsiteTab";
// Mock child components and hooks
const mockAdvancedOptionToggle = vi.fn();
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: (props: any) => {
mockAdvancedOptionToggle(props);
return (
<div data-testid="advanced-option-toggle">
<span>{props.title}</span>
<input type="checkbox" checked={props.isChecked} onChange={() => props.onToggle(!props.isChecked)} />
</div>
);
},
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
}));
const mockCodeBlock = vi.fn();
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: (props: any) => {
mockCodeBlock(props);
return (
<div data-testid="code-block" data-language={props.language}>
{props.children}
</div>
);
},
}));
const mockOptionsSwitch = vi.fn();
vi.mock("@/modules/ui/components/options-switch", () => ({
OptionsSwitch: (props: any) => {
mockOptionsSwitch(props);
return (
<div data-testid="options-switch">
{props.options.map((opt: { value: string; label: string }) => (
<button key={opt.value} onClick={() => props.handleOptionChange(opt.value)}>
{opt.label}
</button>
))}
</div>
);
},
}));
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="next-link">
{children}
</a>
),
}));
const mockWriteText = vi.fn();
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
},
configurable: true,
});
const surveyUrl = "https://app.formbricks.com/s/survey123";
const environmentId = "env456";
describe("WebsiteTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders OptionsSwitch and StaticTab by default", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
expect(screen.getByTestId("options-switch")).toBeInTheDocument();
expect(mockOptionsSwitch).toHaveBeenCalledWith(
expect.objectContaining({
currentOption: "static",
options: [
{ value: "static", label: "environments.surveys.summary.static_iframe" },
{ value: "popup", label: "environments.surveys.summary.dynamic_popup" },
],
})
);
// StaticTab content checks
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
expect(screen.getByTestId("code-block")).toBeInTheDocument();
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.static_iframe")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.dynamic_popup")).toBeInTheDocument();
});
test("switches to PopupTab when 'Dynamic Popup' option is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
expect(mockOptionsSwitch.mock.calls.some((call) => call[0].currentOption === "popup")).toBe(true);
// PopupTab content checks
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument();
expect(screen.getByRole("list")).toBeInTheDocument(); // Check for the ol element
const listItems = screen.getAllByRole("listitem");
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
expect(
screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" })
).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning").closest("video")
).toBeInTheDocument();
});
describe("StaticTab", () => {
const formattedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedBaseCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
const formattedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> \n <iframe \n src="${surveyUrl}?embed=true" \n frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">\n </iframe>\n</div>`;
const normalizedEmbedCode = `<div style="position: relative; height:80dvh; overflow:auto;"> <iframe src="${surveyUrl}?embed=true" frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;"> </iframe> </div>`;
test("renders correctly with initial iframe code and embed mode toggle", () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />); // Defaults to StaticTab
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock).toHaveBeenCalledWith(
expect.objectContaining({ children: formattedBaseCode, language: "html" })
);
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
expect(mockAdvancedOptionToggle).toHaveBeenCalledWith(
expect.objectContaining({
isChecked: false,
title: "environments.surveys.summary.embed_mode",
description: "environments.surveys.summary.embed_mode_description",
})
);
expect(screen.getByText("environments.surveys.summary.embed_mode")).toBeInTheDocument();
});
test("copies iframe code to clipboard when 'Copy Code' is clicked", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const copyButton = screen.getByRole("button", { name: "Embed survey in your website" });
await userEvent.click(copyButton);
expect(mockWriteText).toHaveBeenCalledWith(formattedBaseCode);
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.summary.embed_code_copied_to_clipboard"
);
expect(screen.getByText("common.copy_code")).toBeInTheDocument();
});
test("updates iframe code when 'Embed Mode' is toggled", async () => {
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const embedToggle = screen
.getByTestId("advanced-option-toggle")
.querySelector('input[type="checkbox"]');
expect(embedToggle).not.toBeNull();
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedEmbedCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedEmbedCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === true)).toBe(true);
// Toggle back
await userEvent.click(embedToggle!);
expect(screen.getByTestId("code-block")).toHaveTextContent(normalizedBaseCode);
expect(mockCodeBlock.mock.calls.find((call) => call[0].children === formattedBaseCode)).toBeTruthy();
expect(mockAdvancedOptionToggle.mock.calls.some((call) => call[0].isChecked === false)).toBe(true);
});
});
describe("PopupTab", () => {
beforeEach(async () => {
// Ensure PopupTab is active
render(<WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />);
const popupButton = screen.getByRole("button", {
name: "environments.surveys.summary.dynamic_popup",
});
await userEvent.click(popupButton);
});
test("renders title and instructions", () => {
expect(screen.getByText("environments.surveys.summary.embed_pop_up_survey_title")).toBeInTheDocument();
const listItems = screen.getAllByRole("listitem");
expect(listItems).toHaveLength(3);
expect(listItems[0]).toHaveTextContent(
"common.follow_these environments.surveys.summary.setup_instructions environments.surveys.summary.to_connect_your_website_with_formbricks"
);
expect(listItems[1]).toHaveTextContent(
"environments.surveys.summary.make_sure_the_survey_type_is_set_to common.website_survey"
);
expect(listItems[2]).toHaveTextContent(
"environments.surveys.summary.define_when_and_where_the_survey_should_pop_up"
);
// Specific checks for elements or distinct text content
expect(screen.getByText("environments.surveys.summary.setup_instructions")).toBeInTheDocument(); // Checks the link text
expect(screen.getByText("common.website_survey")).toBeInTheDocument(); // Checks the bold text
// The text for the last list item is its sole content, so getByText works here.
expect(
screen.getByText("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")
).toBeInTheDocument();
});
test("renders the setup instructions link with correct href", () => {
const link = screen.getByRole("link", { name: "environments.surveys.summary.setup_instructions" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", `/environments/${environmentId}/project/website-connection`);
expect(link).toHaveAttribute("target", "_blank");
});
test("renders the video", () => {
const videoElement = screen
.getByText("environments.surveys.summary.unsupported_video_tag_warning")
.closest("video");
expect(videoElement).toBeInTheDocument();
expect(videoElement).toHaveAttribute("autoPlay");
expect(videoElement).toHaveAttribute("loop");
const sourceElement = videoElement?.querySelector("source");
expect(sourceElement).toHaveAttribute("src", "/video/tooltips/change-survey-type.mp4");
expect(sourceElement).toHaveAttribute("type", "video/mp4");
expect(
screen.getByText("environments.surveys.summary.unsupported_video_tag_warning")
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,118 @@
"use client";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { OptionsSwitch } from "@/modules/ui/components/options-switch";
import { useTranslate } from "@tolgee/react";
import { CopyIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import toast from "react-hot-toast";
export const WebsiteTab = ({ surveyUrl, environmentId }) => {
const [selectedTab, setSelectedTab] = useState("static");
const { t } = useTranslate();
return (
<div className="flex h-full grow flex-col">
<OptionsSwitch
options={[
{ value: "static", label: t("environments.surveys.summary.static_iframe") },
{ value: "popup", label: t("environments.surveys.summary.dynamic_popup") },
]}
currentOption={selectedTab}
handleOptionChange={(value) => setSelectedTab(value)}
/>
<div className="mt-4">
{selectedTab === "static" ? (
<StaticTab surveyUrl={surveyUrl} />
) : (
<PopupTab environmentId={environmentId} />
)}
</div>
</div>
);
};
const StaticTab = ({ surveyUrl }) => {
const [embedModeEnabled, setEmbedModeEnabled] = useState(false);
const { t } = useTranslate();
const iframeCode = `<div style="position: relative; height:80dvh; overflow:auto;">
<iframe
src="${surveyUrl}${embedModeEnabled ? "?embed=true" : ""}"
frameborder="0" style="position: absolute; left:0; top:0; width:100%; height:100%; border:0;">
</iframe>
</div>`;
return (
<div className="flex h-full grow flex-col">
<div className="flex justify-between">
<div></div>
<Button
title="Embed survey in your website"
aria-label="Embed survey in your website"
onClick={() => {
navigator.clipboard.writeText(iframeCode);
toast.success(t("environments.surveys.summary.embed_code_copied_to_clipboard"));
}}>
{t("common.copy_code")}
<CopyIcon />
</Button>
</div>
<div className="prose prose-slate max-w-full">
<CodeBlock
customCodeClass="text-sm h-48 overflow-y-scroll text-sm"
language="html"
showCopyToClipboard={false}>
{iframeCode}
</CodeBlock>
</div>
<div className="mt-2 rounded-md border bg-white p-4">
<AdvancedOptionToggle
htmlId="enableEmbedMode"
isChecked={embedModeEnabled}
onToggle={setEmbedModeEnabled}
title={t("environments.surveys.summary.embed_mode")}
description={t("environments.surveys.summary.embed_mode_description")}
childBorder={true}
/>
</div>
</div>
);
};
const PopupTab = ({ environmentId }) => {
const { t } = useTranslate();
return (
<div>
<p className="text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.embed_pop_up_survey_title")}
</p>
<ol className="mt-4 list-decimal space-y-2 pl-5 text-sm text-slate-700">
<li>
{t("common.follow_these")}{" "}
<Link
href={`/environments/${environmentId}/project/website-connection`}
target="_blank"
className="decoration-brand-dark font-medium underline underline-offset-2">
{t("environments.surveys.summary.setup_instructions")}
</Link>{" "}
{t("environments.surveys.summary.to_connect_your_website_with_formbricks")}
</li>
<li>
{t("environments.surveys.summary.make_sure_the_survey_type_is_set_to")}{" "}
<b>{t("common.website_survey")}</b>
</li>
<li>{t("environments.surveys.summary.define_when_and_where_the_survey_should_pop_up")}</li>
</ol>
<div className="mt-4">
<video autoPlay loop muted className="w-full rounded-xl border border-slate-200">
<source src="/video/tooltips/change-survey-type.mp4" type="video/mp4" />
{t("environments.surveys.summary.unsupported_video_tag_warning")}
</video>
</div>
</div>
);
};

View File

@@ -1,433 +0,0 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AnonymousLinksTab } from "./anonymous-links-tab";
// Mock actions
vi.mock("../../actions", () => ({
updateSingleUseLinksAction: vi.fn(),
}));
vi.mock("@/modules/survey/list/actions", () => ({
generateSingleUseIdsAction: vi.fn(),
}));
// Mock components
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
ShareSurveyLink: ({ surveyUrl, publicDomain }: any) => (
<div data-testid="share-survey-link">
<p>Survey URL: {surveyUrl}</p>
<p>Public Domain: {publicDomain}</p>
</div>
),
}));
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
AdvancedOptionToggle: ({ children, htmlId, isChecked, onToggle, title }: any) => (
<div data-testid={`toggle-${htmlId}`} data-checked={isChecked}>
<button data-testid={`toggle-button-${htmlId}`} onClick={() => onToggle(!isChecked)}>
{title}
</button>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant, size }: any) => (
<div data-testid={`alert-${variant}`} data-size={size}>
{children}
</div>
),
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
AlertDescription: ({ children }: any) => <div data-testid="alert-description">{children}</div>,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, disabled, variant }: any) => (
<button onClick={onClick} disabled={disabled} data-variant={variant}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/input", () => ({
Input: ({ value, onChange, type, max, min, className }: any) => (
<input
type={type}
max={max}
min={min}
className={className}
value={value}
onChange={onChange}
data-testid="number-input"
/>
),
}));
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container",
() => ({
TabContainer: ({ children, title }: any) => (
<div data-testid="tab-container">
<h2>{title}</h2>
{children}
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal",
() => ({
DisableLinkModal: ({ open, type, onDisable }: any) => (
<div data-testid="disable-link-modal" data-open={open} data-type={type}>
<button onClick={() => onDisable()}>Confirm</button>
<button>Close</button>
</div>
),
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links",
() => ({
DocumentationLinks: ({ links }: any) => (
<div data-testid="documentation-links">
{links.map((link: any, index: number) => (
<a key={index} href={link.href}>
{link.title}
</a>
))}
</div>
),
})
);
// Mock translations
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock Next.js router
const mockRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRefresh,
}),
}));
// Mock toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
// Mock URL and Blob for download functionality
global.URL.createObjectURL = vi.fn(() => "mock-url");
global.URL.revokeObjectURL = vi.fn();
global.Blob = vi.fn(() => ({}) as any);
describe("AnonymousLinksTab", () => {
const mockSurvey = {
id: "test-survey-id",
environmentId: "test-env-id",
type: "link" as const,
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
createdBy: null,
status: "draft" as const,
questions: [],
thankYouCard: { enabled: false },
welcomeCard: { enabled: false },
hiddenFields: { enabled: false },
singleUse: {
enabled: false,
isEncrypted: false,
},
} as unknown as TSurvey;
const surveyWithSingleUse = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: false,
},
} as TSurvey;
const surveyWithEncryption = {
...mockSurvey,
singleUse: {
enabled: true,
isEncrypted: true,
},
} as TSurvey;
const defaultProps = {
survey: mockSurvey,
surveyUrl: "https://example.com/survey",
publicDomain: "https://example.com",
setSurveyUrl: vi.fn(),
locale: "en-US" as TUserLocale,
};
beforeEach(async () => {
vi.clearAllMocks();
const { updateSingleUseLinksAction } = await import("../../actions");
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: mockSurvey });
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: ["link1", "link2"] });
});
afterEach(() => {
cleanup();
});
test("renders with single-use link enabled when survey has singleUse enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
expect(screen.getByTestId("toggle-multi-use-link-switch")).toHaveAttribute("data-checked", "false");
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
});
test("handles multi-use toggle when single-use is disabled", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} />);
// When multi-use is enabled and we click it, it should show a modal to turn it off
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Should show confirmation modal
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
expect(mockRefresh).toHaveBeenCalled();
});
test("shows confirmation modal when toggling from single-use to multi-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "single-use");
});
test("shows confirmation modal when toggling from multi-use to single-use", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} />);
const singleUseToggle = screen.getByTestId("toggle-button-single-use-link-switch");
await user.click(singleUseToggle);
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-open", "true");
expect(screen.getByTestId("disable-link-modal")).toHaveAttribute("data-type", "multi-use");
});
test("handles single-use encryption toggle", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const encryptionToggle = screen.getByTestId("toggle-button-single-use-encryption-switch");
await user.click(encryptionToggle);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: true,
isSingleUseEncryption: true,
});
});
});
test("shows encryption info alert when encryption is disabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const alerts = screen.getAllByTestId("alert-info");
const encryptionAlert = alerts.find(
(alert) =>
alert.querySelector('[data-testid="alert-title"]')?.textContent ===
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
expect(encryptionAlert).toBeInTheDocument();
expect(encryptionAlert?.querySelector('[data-testid="alert-title"]')).toHaveTextContent(
"environments.surveys.share.anonymous_links.custom_single_use_id_title"
);
});
test("shows link generation section when encryption is enabled", () => {
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
expect(screen.getByTestId("number-input")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.generate_and_download_links")
).toBeInTheDocument();
});
test("handles number of links input change", async () => {
const user = userEvent.setup();
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const input = screen.getByTestId("number-input");
await user.clear(input);
await user.type(input, "5");
expect(input).toHaveValue(5);
});
test("handles link generation error", async () => {
const user = userEvent.setup();
const { generateSingleUseIdsAction } = await import("@/modules/survey/list/actions");
vi.mocked(generateSingleUseIdsAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
const generateButton = screen.getByText(
"environments.surveys.share.anonymous_links.generate_and_download_links"
);
await user.click(generateButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"environments.surveys.share.anonymous_links.generate_links_error"
);
});
});
test("handles action error with generic message", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
vi.mocked(updateSingleUseLinksAction).mockResolvedValue({ data: undefined });
render(<AnonymousLinksTab {...defaultProps} />);
// Click multi-use toggle to show modal
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
// Confirm the modal action
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.something_went_wrong_please_try_again");
});
});
test("confirms modal action when disable link modal is confirmed", async () => {
const user = userEvent.setup();
const { updateSingleUseLinksAction } = await import("../../actions");
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
const multiUseToggle = screen.getByTestId("toggle-button-multi-use-link-switch");
await user.click(multiUseToggle);
const confirmButton = screen.getByText("Confirm");
await user.click(confirmButton);
await waitFor(() => {
expect(updateSingleUseLinksAction).toHaveBeenCalledWith({
surveyId: "test-survey-id",
environmentId: "test-env-id",
isSingleUse: false,
isSingleUseEncryption: false,
});
});
});
test("renders documentation links", () => {
render(<AnonymousLinksTab {...defaultProps} />);
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.single_use_links")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
).toBeInTheDocument();
});
test("shows read-only input with copy button when encryption is disabled", async () => {
// surveyWithSingleUse has encryption disabled
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
// Check if single-use link is enabled
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
// Check if encryption is disabled
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute(
"data-checked",
"false"
);
// Check for the custom URL display
const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`;
expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument();
// Check for the copy button and try to click it
const copyButton = screen.getByText("common.copy");
expect(copyButton).toBeInTheDocument();
await userEvent.click(copyButton);
// check if toast is called
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
// Check for the alert
expect(
screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title")
).toBeInTheDocument();
// Ensure the number of links input is not visible
expect(
screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label")
).not.toBeInTheDocument();
});
test("hides read-only input with copy button when encryption is enabled", async () => {
// surveyWithEncryption has encryption enabled
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
// Check if single-use link is enabled
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
// Check if encryption is enabled
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true");
// Ensure the number of links input is visible
expect(
screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label")
).toBeInTheDocument();
});
});

View File

@@ -1,361 +0,0 @@
"use client";
import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/actions";
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
interface AnonymousLinksTabProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}
export const AnonymousLinksTab = ({
survey,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslate();
const [isMultiUseLink, setIsMultiUseLink] = useState(!survey.singleUse?.enabled);
const [isSingleUseLink, setIsSingleUseLink] = useState(survey.singleUse?.enabled ?? false);
const [singleUseEncryption, setSingleUseEncryption] = useState(survey.singleUse?.isEncrypted ?? false);
const [numberOfLinks, setNumberOfLinks] = useState<number | string>(1);
const [disableLinkModal, setDisableLinkModal] = useState<{
open: boolean;
type: "multi-use" | "single-use";
pendingAction: () => Promise<void> | void;
} | null>(null);
const resetState = () => {
const { singleUse } = survey;
const { enabled, isEncrypted } = singleUse ?? {};
setIsMultiUseLink(!enabled);
setIsSingleUseLink(enabled ?? false);
setSingleUseEncryption(isEncrypted ?? false);
};
const updateSingleUseSettings = async (
isSingleUse: boolean,
isSingleUseEncryption: boolean
): Promise<void> => {
try {
const updatedSurveyResponse = await updateSingleUseLinksAction({
surveyId: survey.id,
environmentId: survey.environmentId,
isSingleUse,
isSingleUseEncryption,
});
if (updatedSurveyResponse?.data) {
router.refresh();
return;
}
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
} catch {
toast.error(t("common.something_went_wrong_please_try_again"));
resetState();
}
};
const handleMultiUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning multi-use on - show confirmation modal if single-use is currently enabled
if (isSingleUseLink) {
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
} else {
// Single-use is already off, just enable multi-use
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
}
} else {
// Turning multi-use off - need confirmation and turn single-use on
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
}
};
const handleSingleUseToggle = async (newValue: boolean) => {
if (newValue) {
// Turning single-use on - turn multi-use off
setDisableLinkModal({
open: true,
type: "multi-use",
pendingAction: async () => {
setIsMultiUseLink(false);
setIsSingleUseLink(true);
setSingleUseEncryption(true);
await updateSingleUseSettings(true, true);
},
});
} else {
// Turning single-use off - show confirmation modal and then turn multi-use on
setDisableLinkModal({
open: true,
type: "single-use",
pendingAction: async () => {
setIsMultiUseLink(true);
setIsSingleUseLink(false);
setSingleUseEncryption(false);
await updateSingleUseSettings(false, false);
},
});
}
};
const handleSingleUseEncryptionToggle = async (newValue: boolean) => {
setSingleUseEncryption(newValue);
await updateSingleUseSettings(true, newValue);
};
const handleNumberOfLinksChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (inputValue === "") {
setNumberOfLinks("");
return;
}
const value = Number(inputValue);
if (!isNaN(value)) {
setNumberOfLinks(value);
}
};
const handleGenerateLinks = async (count: number) => {
try {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: singleUseEncryption,
count,
});
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
// Create content with just the links
const csvContent = surveyLinks.join("\n");
// Create and download the file
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `single-use-links-${survey.id}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
return;
}
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
} catch (error) {
toast.error(t("environments.surveys.share.anonymous_links.generate_links_error"));
}
};
return (
<>
<div className="flex h-full flex-col justify-between space-y-4">
<div className="flex w-full grow flex-col gap-6">
<AdvancedOptionToggle
htmlId="multi-use-link-switch"
isChecked={isMultiUseLink}
onToggle={handleMultiUseToggle}
title={t("environments.surveys.share.anonymous_links.multi_use_link")}
description={t("environments.surveys.share.anonymous_links.multi_use_link_description")}
customContainerClass="pl-1 pr-0 py-0"
childBorder>
<div className="flex w-full flex-col gap-4 overflow-hidden bg-white p-4">
<ShareSurveyLink
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
<div className="w-full">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.multi_use_powers_other_channels_title")}
</AlertTitle>
<AlertDescription>
{t(
"environments.surveys.share.anonymous_links.multi_use_powers_other_channels_description"
)}
</AlertDescription>
</Alert>
</div>
</div>
</AdvancedOptionToggle>
<AdvancedOptionToggle
htmlId="single-use-link-switch"
isChecked={isSingleUseLink}
onToggle={handleSingleUseToggle}
title={t("environments.surveys.share.anonymous_links.single_use_link")}
description={t("environments.surveys.share.anonymous_links.single_use_link_description")}
customContainerClass="pl-1 pr-0 py-0"
childBorder>
<div className="flex w-full flex-col gap-4 bg-white p-4">
<AdvancedOptionToggle
htmlId="single-use-encryption-switch"
isChecked={singleUseEncryption}
onToggle={handleSingleUseEncryptionToggle}
title={t("environments.surveys.share.anonymous_links.url_encryption_label")}
description={t("environments.surveys.share.anonymous_links.url_encryption_description")}
customContainerClass="pl-1 pr-0 py-0"
/>
{!singleUseEncryption ? (
<div className="flex w-full flex-col gap-4">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
</Button>
</div>
</div>
) : null}
{singleUseEncryption && (
<div className="flex w-full flex-col gap-2">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.surveys.share.anonymous_links.number_of_links_label")}
</h3>
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-center gap-2">
<div className="w-32">
<Input
type="number"
max={5000}
min={1}
className="bg-white focus:border focus:border-slate-900"
value={numberOfLinks}
onChange={handleNumberOfLinksChange}
/>
</div>
<Button
variant="default"
onClick={() => handleGenerateLinks(Number(numberOfLinks) || 1)}
disabled={Number(numberOfLinks) < 1 || Number(numberOfLinks) > 5000}>
<div className="flex items-center gap-2">
<CirclePlayIcon className="h-3.5 w-3.5 shrink-0 text-slate-50" />
</div>
<span className="text-sm text-slate-50">
{t("environments.surveys.share.anonymous_links.generate_and_download_links")}
</span>
</Button>
</div>
</div>
</div>
)}
</div>
</AdvancedOptionToggle>
</div>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.anonymous_links.single_use_links"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/single-use-links",
},
{
title: t("environments.surveys.share.anonymous_links.data_prefilling"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/data-prefilling",
},
{
title: t("environments.surveys.share.anonymous_links.source_tracking"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>
</div>
{disableLinkModal && (
<DisableLinkModal
open={disableLinkModal.open}
onOpenChange={() => setDisableLinkModal(null)}
type={disableLinkModal.type}
onDisable={() => {
disableLinkModal.pendingAction();
setDisableLinkModal(null);
}}
/>
)}
</>
);
};

View File

@@ -1,383 +0,0 @@
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { SurveyContextWrapper } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
import { TBaseFilter, TSegment } from "@formbricks/types/segment";
import { TSurvey, TSurveyWelcomeCard } from "@formbricks/types/surveys/types";
import { AppTab } from "./app-tab";
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock DocumentationLinksSection
vi.mock("./documentation-links-section", () => ({
DocumentationLinksSection: ({ title, links }: { title: string; links: any[] }) => (
<div data-testid="documentation-links">
<h4>{title}</h4>
{links.map((link) => (
<div key={link.href} data-testid="documentation-link">
<a href={link.href}>{link.title}</a>
</div>
))}
</div>
),
}));
// Mock segment
const mockSegment: TSegment = {
id: "test-segment-id",
title: "Test Segment",
description: "Test segment description",
environmentId: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
isPrivate: false,
filters: [
{
id: "test-filter-id",
connector: "and",
resource: "contact",
attributeKey: "test-attribute-key",
attributeType: "string",
condition: "equals",
value: "test",
} as unknown as TBaseFilter,
],
surveys: ["test-survey-id"],
};
// Mock action class
const mockActionClass: TActionClass = {
id: "test-action-id",
name: "Test Action",
type: "code",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
description: "Test action description",
noCodeConfig: null,
key: "test-action-key",
};
const mockNoCodeActionClass: TActionClass = {
id: "test-no-code-action-id",
name: "Test No Code Action",
type: "noCode",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
description: "Test no code action description",
noCodeConfig: {
type: "click",
elementSelector: {
cssSelector: ".test-button",
innerHtml: "Click me",
},
} as TActionClassNoCodeConfig,
key: "test-no-code-action-key",
};
// Mock environment data
const mockEnvironment: TEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "test-project-id",
appSetupCompleted: true,
};
// Mock project data
const mockProject = {
id: "test-project-id",
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "test-org-id",
recontactDays: 7,
config: {
channel: "app",
industry: "saas",
},
linkSurveyBranding: true,
styling: {
allowStyleOverwrite: true,
brandColor: { light: "#ffffff", dark: "#000000" },
questionColor: { light: "#000000", dark: "#ffffff" },
inputColor: { light: "#000000", dark: "#ffffff" },
inputBorderColor: { light: "#cccccc", dark: "#444444" },
cardBackgroundColor: { light: "#ffffff", dark: "#000000" },
cardBorderColor: { light: "#cccccc", dark: "#444444" },
highlightBorderColor: { light: "#007bff", dark: "#0056b3" },
isDarkModeEnabled: false,
isLogoHidden: false,
hideProgressBar: false,
roundness: 8,
cardArrangement: { linkSurveys: "casual", appSurveys: "casual" },
},
inAppSurveyBranding: true,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
logo: { url: "test-logo.png", bgColor: "#ffffff" },
} as TProject;
// Mock survey data
const mockSurvey: TSurvey = {
id: "test-survey-id",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Survey",
type: "app",
environmentId: "test-env-id",
status: "inProgress",
displayOption: "displayOnce",
autoClose: null,
triggers: [{ actionClass: mockActionClass }],
recontactDays: null,
displayLimit: null,
welcomeCard: { enabled: false } as unknown as TSurveyWelcomeCard,
questions: [],
endings: [],
hiddenFields: { enabled: false },
displayPercentage: null,
autoComplete: null,
segment: null,
languages: [],
showLanguageSwitch: false,
singleUse: { enabled: false, isEncrypted: false },
projectOverwrites: null,
surveyClosedMessage: null,
delay: 0,
isVerifyEmailEnabled: false,
inlineTriggers: {},
} as unknown as TSurvey;
describe("AppTab", () => {
afterEach(() => {
cleanup();
});
const renderWithProviders = (appSetupCompleted = true, surveyOverrides = {}, projectOverrides = {}) => {
const environmentWithSetup = {
...mockEnvironment,
appSetupCompleted,
};
const surveyWithOverrides = {
...mockSurvey,
...surveyOverrides,
};
const projectWithOverrides = {
...mockProject,
...projectOverrides,
};
return render(
<EnvironmentContextWrapper environment={environmentWithSetup} project={projectWithOverrides}>
<SurveyContextWrapper survey={surveyWithOverrides}>
<AppTab />
</SurveyContextWrapper>
</EnvironmentContextWrapper>
);
};
test("renders setup completed content when app setup is completed", () => {
renderWithProviders(true);
expect(screen.getByText("environments.surveys.summary.in_app.connection_title")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.connection_description")
).toBeInTheDocument();
});
test("renders setup required content when app setup is not completed", () => {
renderWithProviders(false);
expect(screen.getByText("environments.surveys.summary.in_app.no_connection_title")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.no_connection_description")
).toBeInTheDocument();
expect(screen.getByText("common.connect_formbricks")).toBeInTheDocument();
});
test("displays correct wait time when survey has recontact days", () => {
renderWithProviders(true, { recontactDays: 5 });
expect(
screen.getByText("5 environments.surveys.summary.in_app.display_criteria.time_based_days")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays correct wait time when survey has 1 recontact day", () => {
renderWithProviders(true, { recontactDays: 1 });
expect(
screen.getByText("1 environments.surveys.summary.in_app.display_criteria.time_based_day")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays correct wait time when survey has 0 recontact days", () => {
renderWithProviders(true, { recontactDays: 0 });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.overwritten)")
).toBeInTheDocument();
});
test("displays project recontact days when survey has no recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: 3 });
expect(
screen.getByText("3 environments.surveys.summary.in_app.display_criteria.time_based_days")
).toBeInTheDocument();
});
test("displays always when project has 0 recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: 0 });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
});
test("displays always when both survey and project have null recontact days", () => {
renderWithProviders(true, { recontactDays: null }, { recontactDays: null });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_always")
).toBeInTheDocument();
});
test("displays correct display option for displayOnce", () => {
renderWithProviders(true, { displayOption: "displayOnce" });
expect(screen.getByText("environments.surveys.edit.show_only_once")).toBeInTheDocument();
});
test("displays correct display option for displayMultiple", () => {
renderWithProviders(true, { displayOption: "displayMultiple" });
expect(screen.getByText("environments.surveys.edit.until_they_submit_a_response")).toBeInTheDocument();
});
test("displays correct display option for respondMultiple", () => {
renderWithProviders(true, { displayOption: "respondMultiple" });
expect(
screen.getByText("environments.surveys.edit.keep_showing_while_conditions_match")
).toBeInTheDocument();
});
test("displays correct display option for displaySome", () => {
renderWithProviders(true, { displayOption: "displaySome" });
expect(screen.getByText("environments.surveys.edit.show_multiple_times")).toBeInTheDocument();
});
test("displays everyone when survey has no segment", () => {
renderWithProviders(true, { segment: null });
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
).toBeInTheDocument();
});
test("displays targeted when survey has segment with filters", () => {
renderWithProviders(true, {
segment: mockSegment,
});
expect(screen.getByText("Test Segment")).toBeInTheDocument();
});
test("displays segment title when survey has public segment with filters", () => {
const publicSegment = { ...mockSegment, isPrivate: false, title: "Public Segment" };
renderWithProviders(true, {
segment: publicSegment,
});
expect(screen.getByText("Public Segment")).toBeInTheDocument();
});
test("displays targeted when survey has private segment with filters", () => {
const privateSegment = { ...mockSegment, isPrivate: true };
renderWithProviders(true, {
segment: privateSegment,
});
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.targeted")
).toBeInTheDocument();
});
test("displays everyone when survey has segment with no filters", () => {
const emptySegment = { ...mockSegment, filters: [] };
renderWithProviders(true, {
segment: emptySegment,
});
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.everyone")
).toBeInTheDocument();
});
test("displays code trigger description correctly", () => {
renderWithProviders(true, { triggers: [{ actionClass: mockActionClass }] });
expect(screen.getByText("Test Action")).toBeInTheDocument();
expect(
screen.getByText("(environments.surveys.summary.in_app.display_criteria.code_trigger)")
).toBeInTheDocument();
});
test("displays no-code trigger description correctly", () => {
renderWithProviders(true, { triggers: [{ actionClass: mockNoCodeActionClass }] });
expect(screen.getByText("Test No Code Action")).toBeInTheDocument();
expect(
screen.getByText(
"(environments.surveys.summary.in_app.display_criteria.no_code_trigger, environments.actions.click)"
)
).toBeInTheDocument();
});
test("displays randomizer when displayPercentage is set", () => {
renderWithProviders(true, { displayPercentage: 25 });
expect(
screen.getAllByText(/environments\.surveys\.summary\.in_app\.display_criteria\.randomizer/)[0]
).toBeInTheDocument();
});
test("does not display randomizer when displayPercentage is null", () => {
renderWithProviders(true, { displayPercentage: null });
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
});
test("does not display randomizer when displayPercentage is 0", () => {
renderWithProviders(true, { displayPercentage: 0 });
expect(screen.queryByText("Show to")).not.toBeInTheDocument();
});
test("renders documentation links section", () => {
renderWithProviders(true);
expect(screen.getByTestId("documentation-links")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.in_app.documentation_title")).toBeInTheDocument();
});
test("renders all display criteria items", () => {
renderWithProviders(true);
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.time_based_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.audience_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.trigger_description")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.in_app.display_criteria.recontact_description")
).toBeInTheDocument();
});
});

View File

@@ -1,238 +0,0 @@
"use client";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { H4, InlineSmall, Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import {
CodeXmlIcon,
MousePointerClickIcon,
PercentIcon,
Repeat1Icon,
TimerResetIcon,
UsersIcon,
} from "lucide-react";
import Link from "next/link";
import { ReactNode, useMemo } from "react";
import { TActionClass } from "@formbricks/types/action-classes";
import { TSegment } from "@formbricks/types/segment";
import { DocumentationLinksSection } from "./documentation-links-section";
const createDocumentationLinks = (t: ReturnType<typeof useTranslate>["t"]) => [
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#html",
title: t("environments.surveys.summary.in_app.html_embed"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-js",
title: t("environments.surveys.summary.in_app.javascript_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#swift",
title: t("environments.surveys.summary.in_app.ios_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#android",
title: t("environments.surveys.summary.in_app.kotlin_sdk"),
},
{
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/framework-guides#react-native",
title: t("environments.surveys.summary.in_app.react_native_sdk"),
},
];
const createNoCodeConfigType = (t: ReturnType<typeof useTranslate>["t"]) => ({
click: t("environments.actions.click"),
pageView: t("environments.actions.page_view"),
exitIntent: t("environments.actions.exit_intent"),
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
});
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslate>["t"]) => {
if (days === 0) {
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
} else if (days === 1) {
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_day")}`;
} else {
return `${days} ${t("environments.surveys.summary.in_app.display_criteria.time_based_days")}`;
}
};
interface DisplayCriteriaItemProps {
icon: ReactNode;
title: ReactNode;
titleSuffix?: ReactNode;
description: ReactNode;
}
const DisplayCriteriaItem = ({ icon, title, titleSuffix, description }: DisplayCriteriaItemProps) => {
return (
<div className="grid grid-cols-[auto_1fr] gap-x-2">
<div className="flex items-center justify-center">{icon}</div>
<div className="flex items-center">
<Small>
{title} {titleSuffix && <InlineSmall>{titleSuffix}</InlineSmall>}
</Small>
</div>
<div />
<div className="flex items-start">
<Small color="muted" margin="headerDescription">
{description}
</Small>
</div>
</div>
);
};
export const AppTab = () => {
const { t } = useTranslate();
const { environment, project } = useEnvironment();
const { survey } = useSurvey();
const documentationLinks = useMemo(() => createDocumentationLinks(t), [t]);
const noCodeConfigType = useMemo(() => createNoCodeConfigType(t), [t]);
const waitTime = () => {
if (survey.recontactDays !== null) {
return formatRecontactDaysString(survey.recontactDays, t);
}
if (project.recontactDays !== null) {
return formatRecontactDaysString(project.recontactDays, t);
}
return t("environments.surveys.summary.in_app.display_criteria.time_based_always");
};
const displayOption = () => {
if (survey.displayOption === "displayOnce") {
return t("environments.surveys.edit.show_only_once");
} else if (survey.displayOption === "displayMultiple") {
return t("environments.surveys.edit.until_they_submit_a_response");
} else if (survey.displayOption === "respondMultiple") {
return t("environments.surveys.edit.keep_showing_while_conditions_match");
} else if (survey.displayOption === "displaySome") {
return t("environments.surveys.edit.show_multiple_times");
}
// Default fallback for undefined or unexpected displayOption values
return t("environments.surveys.edit.show_only_once");
};
const getTriggerDescription = (
actionClass: TActionClass,
noCodeConfigTypeParam: ReturnType<typeof createNoCodeConfigType>
) => {
if (actionClass.type === "code") {
return `(${t("environments.surveys.summary.in_app.display_criteria.code_trigger")})`;
} else {
const configType = actionClass.noCodeConfig?.type;
let configTypeLabel = "unknown";
if (configType && configType in noCodeConfigTypeParam) {
configTypeLabel = noCodeConfigTypeParam[configType];
} else if (configType) {
configTypeLabel = configType;
}
return `(${t("environments.surveys.summary.in_app.display_criteria.no_code_trigger")}, ${configTypeLabel})`;
}
};
const getSegmentTitle = (segment: TSegment | null) => {
if (segment?.filters?.length && segment.filters.length > 0) {
return segment.isPrivate
? t("environments.surveys.summary.in_app.display_criteria.targeted")
: segment.title;
}
return t("environments.surveys.summary.in_app.display_criteria.everyone");
};
return (
<div className="flex flex-col justify-between space-y-6 pb-4">
<div className="flex flex-col space-y-6">
<Alert variant={environment.appSetupCompleted ? "success" : "warning"} size="default">
<AlertTitle>
{environment.appSetupCompleted
? t("environments.surveys.summary.in_app.connection_title")
: t("environments.surveys.summary.in_app.no_connection_title")}
</AlertTitle>
<AlertDescription>
{environment.appSetupCompleted
? t("environments.surveys.summary.in_app.connection_description")
: t("environments.surveys.summary.in_app.no_connection_description")}
</AlertDescription>
{!environment.appSetupCompleted && (
<AlertButton asChild>
<Link href={`/environments/${environment.id}/project/app-connection`}>
{t("common.connect_formbricks")}
</Link>
</AlertButton>
)}
</Alert>
<div className="flex flex-col space-y-3">
<H4>{t("environments.surveys.summary.in_app.display_criteria")}</H4>
<div
className={
"flex w-full flex-col space-y-4 rounded-xl border border-slate-200 bg-white p-3 text-left shadow-sm"
}>
<DisplayCriteriaItem
icon={<TimerResetIcon className="h-4 w-4" />}
title={waitTime()}
titleSuffix={
survey.recontactDays !== null
? `(${t("environments.surveys.summary.in_app.display_criteria.overwritten")})`
: undefined
}
description={t("environments.surveys.summary.in_app.display_criteria.time_based_description")}
/>
<DisplayCriteriaItem
icon={<UsersIcon className="h-4 w-4" />}
title={getSegmentTitle(survey.segment)}
description={t("environments.surveys.summary.in_app.display_criteria.audience_description")}
/>
{survey.triggers.map((trigger) => (
<DisplayCriteriaItem
key={trigger.actionClass.id}
icon={
trigger.actionClass.type === "code" ? (
<CodeXmlIcon className="h-4 w-4" />
) : (
<MousePointerClickIcon className="h-4 w-4" />
)
}
title={trigger.actionClass.name}
titleSuffix={getTriggerDescription(trigger.actionClass, noCodeConfigType)}
description={t("environments.surveys.summary.in_app.display_criteria.trigger_description")}
/>
))}
{survey.displayPercentage !== null && survey.displayPercentage > 0 && (
<DisplayCriteriaItem
icon={<PercentIcon className="h-4 w-4" />}
title={t("environments.surveys.summary.in_app.display_criteria.randomizer", {
percentage: survey.displayPercentage,
})}
description={t(
"environments.surveys.summary.in_app.display_criteria.randomizer_description",
{
percentage: survey.displayPercentage,
}
)}
/>
)}
<DisplayCriteriaItem
icon={<Repeat1Icon className="h-4 w-4" />}
title={displayOption()}
description={t("environments.surveys.summary.in_app.display_criteria.recontact_description")}
/>
</div>
</div>
</div>
<DocumentationLinksSection
title={t("environments.surveys.summary.in_app.documentation_title")}
links={documentationLinks}
/>
</div>
);
};

View File

@@ -1,95 +0,0 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DisableLinkModal } from "./disable-link-modal";
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const onOpenChange = vi.fn();
const onDisable = vi.fn();
describe("DisableLinkModal", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("should render the modal for multi-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")
).toBeInTheDocument();
expect(
screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)
).toBeInTheDocument();
});
test("should render the modal for single-use link", () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
expect(screen.getByText("common.are_you_sure")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")
).toBeInTheDocument();
});
test("should call onDisable and onOpenChange when the disable button is clicked for multi-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onDisable and onOpenChange when the disable button is clicked for single-use", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="single-use" onDisable={onDisable} />
);
const disableButton = screen.getByText(
"environments.surveys.share.anonymous_links.disable_single_use_link_modal_button"
);
await userEvent.click(disableButton);
expect(onDisable).toHaveBeenCalled();
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should call onOpenChange when the cancel button is clicked", async () => {
render(
<DisableLinkModal open={true} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
const cancelButton = screen.getByText("common.cancel");
await userEvent.click(cancelButton);
expect(onOpenChange).toHaveBeenCalledWith(false);
});
test("should not render the modal when open is false", () => {
const { container } = render(
<DisableLinkModal open={false} onOpenChange={onOpenChange} type="multi-use" onDisable={onDisable} />
);
expect(container.firstChild).toBeNull();
});
});

View File

@@ -1,74 +0,0 @@
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react";
interface DisableLinkModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
type: "multi-use" | "single-use";
onDisable: () => void;
}
export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => {
const { t } = useTranslate();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
<DialogHeader>
<DialogTitle className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogTitle>
</DialogHeader>
<DialogBody>
{type === "multi-use" ? (
<>
<p>
{t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")}
</p>
<br />
<p>
{t(
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
)}
</p>
</>
) : (
<p>{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}</p>
)}
</DialogBody>
<DialogFooter>
<div className="flex w-full flex-col gap-2">
<Button
variant="default"
onClick={() => {
onDisable();
onOpenChange(false);
}}>
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button")
: t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_button")}
</Button>
<Button variant="secondary" onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,38 +0,0 @@
"use client";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { H4 } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
interface DocumentationLink {
href: string;
title: string;
}
interface DocumentationLinksSectionProps {
title: string;
links: DocumentationLink[];
}
export const DocumentationLinksSection = ({ title, links }: DocumentationLinksSectionProps) => {
const { t } = useTranslate();
return (
<div className="flex w-full flex-col space-y-3">
<H4>{title}</H4>
{links.map((link) => (
<Alert key={link.title} size="small" variant="default">
<ArrowUpRight className="size-4" />
<AlertTitle>{link.title}</AlertTitle>
<AlertButton>
<Link href={link.href} target="_blank" rel="noopener noreferrer">
{t("common.read_docs")}
</Link>
</AlertButton>
</Alert>
))}
</div>
);
};

View File

@@ -1,102 +0,0 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { DocumentationLinks } from "./documentation-links";
describe("DocumentationLinks", () => {
afterEach(() => {
cleanup();
});
const mockLinks = [
{
title: "Getting Started Guide",
href: "https://docs.formbricks.com/getting-started",
},
{
title: "API Documentation",
href: "https://docs.formbricks.com/api",
},
{
title: "Integration Guide",
href: "https://docs.formbricks.com/integrations",
},
];
test("renders all documentation links", () => {
render(<DocumentationLinks links={mockLinks} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("API Documentation")).toBeInTheDocument();
expect(screen.getByText("Integration Guide")).toBeInTheDocument();
});
test("renders correct number of alert components", () => {
render(<DocumentationLinks links={mockLinks} />);
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(3);
});
test("renders learn more links with correct href attributes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
expect(learnMoreLinks).toHaveLength(3);
expect(learnMoreLinks[0]).toHaveAttribute("href", "https://docs.formbricks.com/getting-started");
expect(learnMoreLinks[1]).toHaveAttribute("href", "https://docs.formbricks.com/api");
expect(learnMoreLinks[2]).toHaveAttribute("href", "https://docs.formbricks.com/integrations");
});
test("renders learn more links with target blank", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
});
});
test("renders learn more links with correct CSS classes", () => {
render(<DocumentationLinks links={mockLinks} />);
const learnMoreLinks = screen.getAllByText("common.learn_more");
learnMoreLinks.forEach((link) => {
expect(link).toHaveClass("text-slate-900", "hover:underline");
});
});
test("renders empty list when no links provided", () => {
render(<DocumentationLinks links={[]} />);
const alerts = screen.queryAllByRole("alert");
expect(alerts).toHaveLength(0);
});
test("renders single link correctly", () => {
const singleLink = [mockLinks[0]];
render(<DocumentationLinks links={singleLink} />);
expect(screen.getByText("Getting Started Guide")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toHaveAttribute(
"href",
"https://docs.formbricks.com/getting-started"
);
});
test("renders with correct container structure", () => {
const { container } = render(<DocumentationLinks links={mockLinks} />);
const mainContainer = container.firstChild as HTMLElement;
expect(mainContainer).toHaveClass("flex", "w-full", "flex-col", "space-y-2");
const linkContainers = mainContainer.children;
expect(linkContainers).toHaveLength(3);
Array.from(linkContainers).forEach((linkContainer) => {
expect(linkContainer).toHaveClass("flex", "w-full", "flex-col", "gap-3");
});
});
});

View File

@@ -1,37 +0,0 @@
"use client";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DocumentationLinksProps {
links: {
title: string;
href: string;
}[];
}
export const DocumentationLinks = ({ links }: DocumentationLinksProps) => {
const { t } = useTranslate();
return (
<div className="flex w-full flex-col space-y-2">
{links.map((link) => (
<div key={link.title} className="flex w-full flex-col gap-3">
<Alert variant="outbound" size="small">
<AlertTitle>{link.title}</AlertTitle>
<AlertButton asChild>
<Link
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-slate-900 hover:underline">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</div>
))}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show More