Compare commits

..

80 Commits

Author SHA1 Message Date
harshsbhat
d2343cb60c requested changes 2025-07-22 14:00:36 +05:30
harshsbhat
e10a42f61e Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-22 10:25:43 +05:30
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
Matti Nannt
b35cabcbcc chore(infra): enable cluster public access to mitigate tailscale issues (#6264) 2025-07-19 08:53:31 +02:00
Matti Nannt
4f435f1a1f fix: enable Tailscale subnet routes for EKS access (#6263) 2025-07-18 21:32:01 +02:00
Victor Hugo dos Santos
99c1e434df feat: Deploy to staging on pre-release builds (#6261) 2025-07-18 15:35:00 +00:00
Piyush Gupta
b13699801b fix: survey preview for suid enabled surveys (#6253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-18 08:54:48 +00:00
Jakob Schott
ceb2e85d96 chore: 742 storybook setup and cursor rule (#6220)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-18 08:03:39 +00:00
Anshuman Pandey
c5f8b5ec32 fix: removes suid UI from the survey-editor (#6249)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-18 07:41:05 +00:00
Anshuman Pandey
bdbd57c2fc fix: adds read only survey url (#6252) 2025-07-18 05:14:32 +00:00
harshsbhat
1319ca648c delete integration button 2025-07-18 10:11:12 +05:30
Victor Hugo dos Santos
d44aa17814 feat: add sentry sourcemaps to pre-releases (#6242) 2025-07-17 16:11:28 +00:00
Jakob Schott
23d38b4c5b chore: move tab component to storybook (#6214) 2025-07-17 09:26:31 +00:00
Piyush Gupta
58213969e8 feat: remove brevo contact on account deletion (#6231)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 16:00:34 +00:00
pandeymangg
f6a544d01e fixes dep resolution with tar-fs 2025-07-16 18:59:47 +05:30
harshsbhat
697c132581 fix: resolve merge conflict in pnpm-lock.yaml 2025-07-16 18:38:23 +05:30
harshsbhat
a53e9a1bee sonar qube issues 2025-07-16 18:35:07 +05:30
Victor Hugo dos Santos
ef973c8995 chore: merge rate limiter epic branch into main (#6236)
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Suraj <surajsuthar0067@gmail.com>
Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-07-16 12:28:59 +00:00
dependabot[bot]
bea02ba3b5 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 10:42:54 +00:00
harshsbhat
64fd5e40d7 remove console.log 2025-07-16 15:26:21 +05:30
harshsbhat
b3886014cb console.log 2025-07-16 14:49:44 +05:30
Piyush Jain
1c1e2ee09c chore: add timeout settings for production LB (#5884) 2025-07-16 09:08:11 +00:00
Piyush Gupta
2bf7fe6c54 docs: adds email address validation note (#6239) 2025-07-16 01:55:21 -07:00
harshsbhat
44c5bec535 remove previous changes and reduce duplicate 2025-07-16 13:55:16 +05:30
Saurav Jain
9639402c39 fix: allow read and write API key permissions for /v1/management/me (#6178)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-16 07:52:10 +00:00
harshsbhat
416f142385 chore: reduce duplicate code 2025-07-16 12:52:33 +05:30
harshsbhat
d3d9e3223d reduce duplicate code 2025-07-16 12:21:27 +05:30
harshsbhat
130ed59677 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-16 09:40:06 +05:30
harshsbhat
7d8d7fc744 remove console.log 2025-07-16 00:41:50 +05:30
Victor Hugo dos Santos
53213b41ee feat: New share modal - "In App" tab (#6225)
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Jakob Schott <jakob@formbricks.com>
2025-07-15 17:53:47 +00:00
Dhruwang Jariwala
b8b5eead7a fix: close survey on response limit setting behaviour (#6203)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-15 16:36:03 +00:00
harshsbhat
721ae66811 chore: duplicate code 2025-07-15 22:00:15 +05:30
harshsbhat
af6d9542e4 chore: add more test coverage 2025-07-15 21:25:34 +05:30
harshsbhat
71b408e066 chore: slack build error 2025-07-15 20:37:46 +05:30
harshsbhat
c7277bb709 chore: update the plain connection 2025-07-15 20:14:28 +05:30
harshsbhat
04a709c6c2 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 20:10:07 +05:30
Jakob Schott
a0044ce376 chore: reduced the breakpoint (#6232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-15 13:49:26 +00:00
Piyush Gupta
b3a1f24683 fix: emails font size (#6228) 2025-07-15 13:37:13 +00:00
Dhruwang Jariwala
f06d48698a feat: social media tab (#6219)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-15 13:28:32 +00:00
harshsbhat
4ee0b9ec03 chore: add tests 2025-07-15 18:32:16 +05:30
harshsbhat
eb8eac8aa4 lint errors 2025-07-15 17:21:17 +05:30
harshsbhat
c1444f8427 add tests and translations 2025-07-15 16:58:56 +05:30
harshsbhat
15adaf6976 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-07-15 15:06:16 +05:30
Anshuman Pandey
acd508ba19 feat: sharing modal anonymous links (#6224)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-15 08:03:10 +00:00
harshsbhat
85fababd57 add everything in pipeline 2025-07-14 22:49:11 +05:30
Piyush Gupta
e5591686b4 fix: source tracking in link surveys (#6209) 2025-07-14 09:23:22 -07:00
Dhruwang Jariwala
7be7466eee feat: qr code tab (#6212)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-14 10:53:52 +00:00
harshsbhat
3694f93429 Mege conflicts 2025-07-14 09:21:35 +05:30
harshsbhat
36e0e62f01 plain integration 2025-07-14 09:02:25 +05:30
Victor Hugo dos Santos
8af6c15998 feat: new share modal website embed and pop out (#6217) 2025-07-11 12:45:42 +00:00
Piyush Gupta
17d60eb1e7 feat: revamp sharing modal shell (#6190)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-11 04:17:43 +00:00
Johannes
d6ecafbc23 docs: add hidden fields for SDK note (#6215) 2025-07-10 07:35:09 -07:00
Dhruwang Jariwala
599e847686 chore: removed integrity hash chain from audit logging (#6202) 2025-07-10 10:43:57 +00:00
Victor Hugo dos Santos
4e52556f7e feat: add single contact using the API V2 (#6168) 2025-07-10 10:34:18 +00:00
Kshitij Sharma
492a59e7de fix: show multi-choice question first in styling preview (#6150)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 01:41:02 -07:00
Jakob Schott
e0be53805e fix: Spelling mistake for Nodemailer in docs (#5988) 2025-07-10 00:29:50 -07:00
Johannes
5c2860d1a4 docs: Personal Link docs (#6034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 00:13:29 -07:00
Piyush Gupta
18ba5bbd8a fix: types in audit log wrapper (#6200) 2025-07-10 03:55:28 +00:00
Johannes
572b613034 docs: update prefilling docs (#6062) 2025-07-09 08:52:53 -07:00
Abhi-Bohora
a9c7140ba6 fix: Edit Recall button flicker when user types into the edit field (#6121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-09 08:51:42 -07:00
Abhishek Sharma
7fa95cd74a fix: recall fallback input to be displayed on top of other contai… (#6124)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-09 08:51:27 -07:00
Nathanaël
8c7f36d496 chore: Update docker-compose.yml, fix syntax (#6158) 2025-07-09 17:39:58 +02:00
Jakob Schott
42dcbd3e7e chore: changed date format on license alert to MMM dd, YYYY (#6182) 2025-07-09 14:57:04 +00:00
Piyush Gupta
1c1cd99510 fix: unsaved survey dialog (#6201) 2025-07-09 08:14:32 +00:00
Dhruwang Jariwala
b0a7e212dd fix: suid copy issue on safari (#6174) 2025-07-08 10:50:02 +00:00
Dhruwang Jariwala
0c1f6f3c3a fix: translations (#6186) 2025-07-08 08:52:36 +00:00
Matti Nannt
9399b526b8 fix: run PR checks on every pull requests (#6185) 2025-07-08 11:07:03 +02:00
Dhruwang Jariwala
cd60032bc9 fix: row/column deletion in matrix question (#6184) 2025-07-08 07:12:16 +00:00
Dhruwang Jariwala
a941f994ea fix: removed userId from contact endpoint response (#6175)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-08 06:36:56 +00:00
harshsbhat
6d2bd9210c Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-28 11:29:48 +05:30
harshsbhat
636374ae04 add mapping 2025-06-16 23:43:21 +05:30
harshsbhat
b0627fffa5 tweaks 2025-06-16 13:44:00 +05:30
harshsbhat
84a94ad027 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-06-16 10:47:28 +05:30
harshsbhat
55a1b95988 temp 2025-05-22 16:12:15 +05:30
harshsbhat
bdf1698c05 Merge remote-tracking branch 'origin/main' into harsh/plain-integration 2025-05-22 14:50:49 +05:30
harshsbhat
c761f51b0e first commit 2025-05-20 18:44:50 +05:30
348 changed files with 25857 additions and 6584 deletions

View File

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

View File

@@ -0,0 +1,177 @@
---
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,7 +189,6 @@ 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)
@@ -219,7 +218,7 @@ UNKEY_ROOT_KEY=
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
# Audit logs options. 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,6 +1,7 @@
name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug
projects: "formbricks/8"
labels: ["bug"]
body:
- type: textarea

View File

@@ -11,6 +11,10 @@ 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'
@@ -107,7 +111,7 @@ runs:
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: production
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: './extracted-next/'

View File

@@ -17,8 +17,8 @@ on:
required: true
type: choice
options:
- stage
- prod
- staging
- production
workflow_call:
inputs:
VERSION:
@@ -52,6 +52,7 @@ 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
@@ -66,8 +67,8 @@ jobs:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'prod'
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -84,8 +85,8 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'stage'
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -101,13 +102,13 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
# Set hostname based on environment
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"

View File

@@ -89,6 +89,7 @@ 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
@@ -102,6 +103,12 @@ 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,17 +1,22 @@
name: Build, release & deploy Formbricks images
on:
workflow_dispatch:
push:
tags:
- "v*"
release:
types: [published]
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
name: Build & release docker image
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release:
name: Release Helm Chart
@@ -31,7 +36,7 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: "prod"
ENVIRONMENT: ${{ env.ENVIRONMENT }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
@@ -54,3 +59,4 @@ 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,8 +10,6 @@ permissions:
on:
pull_request:
branches:
- main
merge_group:
workflow_dispatch:

View File

@@ -29,6 +29,10 @@ 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
@@ -38,6 +42,56 @@ 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
env:
VERSION: ${{ env.VERSION }}
run: |
cd ./apps/web
npm version $VERSION --no-git-tag-version
echo "Updated version to: $(npm pkg get 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
@@ -83,6 +137,21 @@ 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
@@ -97,3 +166,25 @@ 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,6 +7,12 @@ 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
@@ -45,10 +51,12 @@ 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: |
@@ -81,6 +89,13 @@ 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,6 +43,7 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
run: |

View File

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

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: "test-redis-url",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
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: "redis://localhost:6379",
REDIS_URL: undefined,
}));
vi.mock("@/lib/env", () => ({

View File

@@ -101,6 +101,7 @@ 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

@@ -0,0 +1,157 @@
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

@@ -0,0 +1,45 @@
"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;
}
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,
}),
[environment, project]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};

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: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -0,0 +1,116 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
interface Column<T> {
/** Header text rendered in the table head */
header: React.ReactNode;
/** Cell renderer for an item */
render: (item: T) => React.ReactNode;
}
interface ActionButtonProps {
label: string;
onClick: () => void;
/** Optional Lucide Icon */
icon?: React.ReactNode;
/** Tooltip content */
tooltip?: string;
/** Variant override */
variant?: "default" | "outline" | "secondary" | "destructive" | "ghost";
}
interface IntegrationListPanelProps<T> {
readonly environment: TEnvironment;
readonly statusNode: React.ReactNode;
readonly reconnectAction: ActionButtonProps;
readonly addNewAction: ActionButtonProps;
readonly emptyMessage: string;
readonly items: T[];
readonly columns: Column<T>[];
readonly onRowClick: (index: number) => void;
readonly getRowKey?: (item: T, index: number) => string | number;
}
export function IntegrationListPanel<T>({
environment,
statusNode,
reconnectAction,
addNewAction,
emptyMessage,
items,
columns,
onRowClick,
getRowKey,
}: IntegrationListPanelProps<T>) {
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{/* Toolbar */}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">{statusNode}</div>
{/* Re-connect */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={reconnectAction.variant ?? "outline"} onClick={reconnectAction.onClick}>
{reconnectAction.icon}
{reconnectAction.label}
</Button>
</TooltipTrigger>
{reconnectAction.tooltip && <TooltipContent>{reconnectAction.tooltip}</TooltipContent>}
</Tooltip>
</TooltipProvider>
{/* Add new */}
<Button variant={addNewAction.variant ?? "default"} onClick={addNewAction.onClick}>
{addNewAction.icon}
{addNewAction.label}
</Button>
</div>
{/* Empty table view */}
{!items || items.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={emptyMessage}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{columns.map((col) => (
<div key={`hdr-${String(col.header)}`} className="col-span-2 hidden text-center sm:block">
{col.header}
</div>
))}
</div>
{items.map((item, index) => {
const key = getRowKey ? getRowKey(item, index) : index;
return (
<button
key={key}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => onRowClick(index)}>
{columns.map((col) => (
<div key={`cell-${String(col.header)}`} className="col-span-2 text-center">
{col.render(item)}
</div>
))}
</button>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { getLocalizedValue } from "@/lib/i18n/utils";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { TFnType } from "@tolgee/react";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
export interface QuestionItem {
id: string;
name: string;
type: TSurveyQuestionTypeEnum;
}
/**
* Build a flat list of selectable question / metadata items for integrations.
* Extracted to avoid duplication between integration modals.
*/
export const buildQuestionItems = (
selectedSurvey: TSurvey | null | undefined,
t: TFnType
): QuestionItem[] => {
const questions: QuestionItem[] = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
})) || []
: [];
const variables: QuestionItem[] =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields: QuestionItem[] = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const metadata: QuestionItem[] = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt: QuestionItem[] = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...metadata, ...createdAt];
};

View File

@@ -1,15 +1,14 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
import NotionLogo from "@/images/notion.png";
import { getLocalizedValue } from "@/lib/i18n/utils";
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 {
@@ -35,7 +34,7 @@ import {
TIntegrationNotionConfigData,
TIntegrationNotionDatabase,
} from "@formbricks/types/integration/notion";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TSurvey } from "@formbricks/types/surveys/types";
interface AddIntegrationModalProps {
environmentId: string;
@@ -118,47 +117,7 @@ export const AddIntegrationModal = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDatabase?.id]);
const questionItems = useMemo(() => {
const questions = selectedSurvey
? replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((q) => ({
id: q.id,
name: getLocalizedValue(q.headline, "default"),
type: q.type,
}))
: [];
const variables =
selectedSurvey?.variables.map((variable) => ({
id: variable.id,
name: variable.name,
type: TSurveyQuestionTypeEnum.OpenText,
})) || [];
const hiddenFields = selectedSurvey?.hiddenFields.enabled
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
id: fId,
name: `${t("common.hidden_field")} : ${fId}`,
type: TSurveyQuestionTypeEnum.OpenText,
})) || []
: [];
const Metadata = [
{
id: "metadata",
name: t("common.metadata"),
type: TSurveyQuestionTypeEnum.OpenText,
},
];
const createdAt = [
{
id: "createdAt",
name: t("common.created_at"),
type: TSurveyQuestionTypeEnum.Date,
},
];
return [...questions, ...variables, ...hiddenFields, ...Metadata, ...createdAt];
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedSurvey?.id]);
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
useEffect(() => {
if (selectedIntegration) {

View File

@@ -5,8 +5,6 @@ import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
@@ -14,6 +12,7 @@ import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
interface ManageIntegrationProps {
environment: TEnvironment;
@@ -70,78 +69,58 @@ export const ManageIntegration = ({
};
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
</>
}
reconnectAction={{
label: t("environments.integrations.notion.update_connection"),
onClick: handleNotionAuthorization,
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.notion.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.notion.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.notion.link_new_database")}
},
}}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationNotionConfigData) => item.surveyName,
},
{
header: t("environments.integrations.notion.database_name"),
render: (item: TIntegrationNotionConfigData) => item.databaseName,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationNotionConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationNotionConfigData, idx) => `${idx}-${item.databaseId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptySpaceFiller
type="table"
environment={environment}
noWidgetRequired={true}
emptyMessage={t("environments.integrations.notion.no_databases_found")}
/>
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
<div className="mt-6 w-full rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.notion.database_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
{integrationArray &&
integrationArray.map((data, index) => {
return (
<button
key={`${index}-${data.databaseId}`}
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
onClick={() => {
editIntegration(index);
}}>
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.databaseName}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), locale)}
</div>
</button>
);
})}
</div>
</div>
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
@@ -151,6 +130,6 @@ export const ManageIntegration = ({
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>
</>
);
};

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: "mock-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -6,6 +6,7 @@ import JsLogo from "@/images/jslogo.png";
import MakeLogo from "@/images/make-small.png";
import n8nLogo from "@/images/n8n.png";
import notionLogo from "@/images/notion.png";
import PlainCom from "@/images/plain.webp";
import SlackLogo from "@/images/slacklogo.png";
import WebhookLogo from "@/images/webhook.png";
import ZapierLogo from "@/images/zapier-small.png";
@@ -50,6 +51,7 @@ const Page = async (props) => {
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
const isNotionIntegrationConnected = isIntegrationConnected("notion");
const isPlainIntegrationConnected = isIntegrationConnected("plain");
const isAirtableIntegrationConnected = isIntegrationConnected("airtable");
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
@@ -207,6 +209,20 @@ const Page = async (props) => {
: `${activePiecesWebhookCount} ${t("common.integrations")}`,
disabled: isReadOnly,
},
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/activepieces",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/integrations/plain`,
connectText: `${isPlainIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
label: "Plain",
description: t("environments.integrations.plain.plain_integration_description"),
icon: <Image src={PlainCom} alt="Plain.com Logo" />,
connected: isPlainIntegrationConnected,
statusText: isPlainIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
];
integrationCards.unshift({

View File

@@ -0,0 +1,62 @@
"use server";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricEncrypt } from "@/lib/crypto";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import type { TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
const ZConnectPlainIntegration = z.object({
environmentId: ZId,
key: z.string().min(1),
});
export const connectPlainIntegrationAction = authenticatedActionClient
.schema(ZConnectPlainIntegration)
.action(async ({ ctx, parsedInput }) => {
const { environmentId, key } = parsedInput;
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const projectId = await getProjectIdFromEnvironmentId(environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const encryptedAccessToken = symmetricEncrypt(key, ENCRYPTION_KEY);
const existingIntegration = await getIntegrationByType(environmentId, "plain");
const plainData: TIntegrationPlainConfigData[] =
existingIntegration?.type === "plain"
? (existingIntegration.config.data as TIntegrationPlainConfigData[])
: [];
const integration = await createOrUpdateIntegration(environmentId, {
type: "plain",
config: {
key: encryptedAccessToken,
data: plainData,
},
});
return {
success: true,
integration,
};
});

View File

@@ -0,0 +1,567 @@
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddIntegrationModal";
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 {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
// Mock actions and utilities
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
createOrUpdateIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: (value: any, _locale: string) => value?.default || "",
}));
vi.mock("@/lib/pollyfills/structuredClone", () => ({
structuredClone: (obj: any) => JSON.parse(JSON.stringify(obj)),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: (survey: any) => survey,
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, loading, variant, type = "button" }: any) => (
<button onClick={onClick} disabled={loading} data-variant={variant} type={type}>
{loading ? "Loading..." : children}
</button>
),
}));
vi.mock("@/modules/ui/components/dropdown-selector", () => ({
DropdownSelector: ({ label, items, selectedItem, setSelectedItem, placeholder, disabled }: any) => {
// Ensure the selected item is always available as an option
const allOptions = [...items];
if (selectedItem && !items.some((item: any) => item.id === selectedItem.id)) {
// Use a simple object structure consistent with how options are likely used
allOptions.push({ id: selectedItem.id, name: selectedItem.name });
}
// Remove duplicates just in case
const uniqueOptions = Array.from(new Map(allOptions.map((item) => [item.id, item])).values());
return (
<div>
{label && <label>{label}</label>}
<select
data-testid={`dropdown-${label?.toLowerCase().replace(/\s+/g, "-") || placeholder?.toLowerCase().replace(/\s+/g, "-")}`}
value={selectedItem?.id || ""} // Still set value based on selectedItem prop
onChange={(e) => {
const selected = uniqueOptions.find((item: any) => item.id === e.target.value);
setSelectedItem(selected);
}}
disabled={disabled}>
<option value="">{placeholder || "Select..."}</option>
{/* Render options from the potentially augmented list */}
{uniqueOptions.map((item: any) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
);
},
}));
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("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>,
}));
vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
}));
vi.mock("react-hook-form", () => ({
useForm: () => ({
handleSubmit: (callback: any) => (event: any) => {
event.preventDefault();
return callback();
},
register: vi.fn(),
setValue: vi.fn(),
watch: vi.fn(),
formState: { errors: {} },
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@tolgee/react", async () => {
const MockTolgeeProvider = ({ children }: { children: React.ReactNode }) => <>{children}</>;
const useTranslate = () => ({
t: (key: string) => {
// Simple mock translation function
if (key === "common.warning") return "Warning";
if (key === "common.metadata") return "Metadata";
if (key === "common.created_at") return "Created at";
if (key === "common.hidden_field") return "Hidden Field";
if (key === "common.first_name") return "First Name";
if (key === "common.last_name") return "Last Name";
if (key === "common.email") return "Email";
if (key === "common.select_survey") return "Select survey";
if (key === "common.delete") return "Delete";
if (key === "common.cancel") return "Cancel";
if (key === "common.update") return "Update";
if (key === "environments.integrations.plain.configure_plain_integration")
return "Configure Plain Integration";
if (key === "environments.integrations.plain.plain_integration_description")
return "Connect your Plain account to send survey responses as threads.";
if (key === "environments.integrations.plain.plain_logo") return "Plain logo";
if (key === "environments.integrations.plain.map_formbricks_fields_to_plain")
return "Map Formbricks fields to Plain";
if (key === "environments.integrations.plain.select_a_survey_question")
return "Select a survey question";
if (key === "environments.integrations.plain.select_a_field_to_map") return "Select a field to map";
if (key === "environments.integrations.plain.enter_label_id") return "Enter Label ID";
if (key === "environments.integrations.plain.connect") return "Connect";
if (key === "environments.integrations.plain.no_contact_info_question")
return "No contact info question found in survey";
if (key === "environments.integrations.plain.contact_info_missing_fields")
return "Contact info question is missing required fields:";
if (key === "environments.integrations.plain.contact_info_warning") return "Contact Info Warning";
if (key === "environments.integrations.plain.contact_info_missing_fields_description")
return "The following fields are missing";
if (key === "environments.integrations.plain.please_select_at_least_one_mapping")
return "Please select at least one mapping.";
if (key === "environments.integrations.plain.please_resolve_mapping_errors")
return "Please resolve mapping errors.";
if (key === "environments.integrations.plain.please_complete_mapping_fields")
return "Please complete mapping fields.";
if (key === "environments.integrations.please_select_a_survey_error") return "Please select a survey.";
if (key === "environments.integrations.create_survey_warning")
return "You need to create a survey first.";
if (key === "environments.integrations.integration_updated_successfully")
return "Integration updated successfully.";
if (key === "environments.integrations.integration_added_successfully")
return "Integration added successfully.";
if (key === "environments.integrations.integration_removed_successfully")
return "Integration removed successfully.";
return key; // Return key if no translation is found
},
});
return { TolgeeProvider: MockTolgeeProvider, useTranslate };
});
// Mock dependencies
const createOrUpdateIntegrationAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
.createOrUpdateIntegrationAction
);
const toast = vi.mocked((await import("react-hot-toast")).default);
const environmentId = "test-env-id";
const mockSetOpen = vi.fn();
// Create a mock survey with a ContactInfo question
const surveys: TSurvey[] = [
{
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 1",
type: "app",
environmentId: environmentId,
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1?" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: true },
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [{ id: "var1", name: "Variable 1" }],
hiddenFields: { enabled: true, fieldIds: ["hf1"] },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
id: "survey2",
createdAt: new Date(),
updatedAt: new Date(),
name: "Survey 2",
type: "link",
environmentId: environmentId,
status: "draft",
questions: [
{
id: "q3",
type: TSurveyQuestionTypeEnum.ContactInfo,
headline: { default: "Partial Contact Info" },
required: true,
firstName: { show: true },
lastName: { show: false }, // Missing lastName
email: { show: true },
} as unknown as TSurveyQuestion,
],
variables: [],
hiddenFields: { enabled: false },
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
autoComplete: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
segment: null,
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration1",
type: "plain",
environmentId: environmentId,
config: {
key: "test-api-key",
data: [], // Initially empty
},
};
const mockSelectedIntegration: TIntegrationPlainConfigData & { index: number } = {
surveyId: surveys[0].id,
surveyName: surveys[0].name,
mapping: [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "q1", name: "Question 1?", type: TSurveyQuestionTypeEnum.OpenText },
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "var1", name: "Variable 1", type: TSurveyQuestionTypeEnum.OpenText },
},
],
includeCreatedAt: true,
includeComponents: true,
labelId: "custom-label",
createdAt: new Date(),
index: 0,
};
describe("AddIntegrationModal (Plain)", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
// Reset integration data before each test if needed
mockPlainIntegration.config.data = [
{ ...mockSelectedIntegration }, // Simulate existing data for update/delete tests
];
});
test("renders correctly when open (create mode)", () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByText("Configure Plain Integration")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect" })).toBeInTheDocument();
expect(screen.queryByText("Delete")).not.toBeInTheDocument();
});
test("renders correctly when open (update mode)", async () => {
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if mapping rows are rendered
await waitFor(() => {
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Expecting two rows based on mockSelectedIntegration
});
expect(screen.getByText("Delete")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update" })).toBeInTheDocument();
});
test("shows survey selection and enables mapping when survey is selected", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={{
...mockPlainIntegration,
config: { ...mockPlainIntegration.config, data: [] },
}}
selectedIntegration={null}
/>
);
// Select a survey
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Check if mapping section appears
expect(screen.getByText("Map Formbricks fields to Plain")).toBeInTheDocument();
// Check if default mapping rows are present
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
expect(questionDropdowns).toHaveLength(2); // Two default mapping rows
});
test("adds and removes mapping rows", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select a survey first
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[0].id);
// Initial mapping rows
let plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Two default rows
// Add a new row
await user.click(plusButtons[0]);
// Check if a new row was added
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(3); // Now three rows
// Try to remove a row (not the mandatory ones)
const trashButtons = screen.getAllByTestId("trash-icon");
expect(trashButtons).toHaveLength(1); // Only the new row should be removable
await user.click(trashButtons[0]);
// Check if row was removed
plusButtons = screen.getAllByTestId("plus-icon");
expect(plusButtons).toHaveLength(2); // Back to two rows
});
test("shows warning for survey with incomplete contact info", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Select survey with incomplete contact info
const surveyDropdown = screen.getByTestId("dropdown-select-survey");
await user.selectOptions(surveyDropdown, surveys[1].id);
// Check if warning appears
expect(screen.getByText("Contact Info Warning")).toBeInTheDocument();
expect(screen.getByText(/Last Name/)).toBeInTheDocument(); // Missing field
});
test("handles form submission with validation errors", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Try to submit without selecting a survey
const connectButton = screen.getByRole("button", { name: "Connect" });
await user.click(connectButton);
// Check if error toast was shown
expect(toast.error).toHaveBeenCalledWith("Please select a survey.");
});
test("handles successful integration update", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Change a mapping
const questionDropdowns = screen.getAllByTestId("dropdown-select-a-survey-question");
await user.selectOptions(questionDropdowns[0], "q2"); // Change to Contact Info question
// Submit the form
const updateButton = screen.getByRole("button", { name: "Update" });
await user.click(updateButton);
// Check if integration was updated
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration updated successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("handles integration deletion", async () => {
const user = userEvent.setup();
createOrUpdateIntegrationAction.mockResolvedValue({});
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={mockSelectedIntegration}
/>
);
// Click delete button
const deleteButton = screen.getByRole("button", { name: "Delete" });
await user.click(deleteButton);
// Check if integration was deleted
await waitFor(() => {
expect(createOrUpdateIntegrationAction).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("Integration removed successfully.");
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});
test("calls setOpen(false) and resets form on cancel", async () => {
const user = userEvent.setup();
render(
<AddIntegrationModal
environmentId={environmentId}
open={true}
surveys={surveys}
setOpen={mockSetOpen}
plainIntegration={mockPlainIntegration}
selectedIntegration={null}
/>
);
// Click cancel button
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await user.click(cancelButton);
// Check if modal was closed
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,627 @@
"use client";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { buildQuestionItems } from "@/app/(app)/environments/[environmentId]/integrations/lib/questionItems";
import PlainLogo from "@/images/plain.webp";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
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 { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react";
import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TIntegrationInput } from "@formbricks/types/integration";
import {
TIntegrationPlain,
TIntegrationPlainConfigData,
TPlainFieldType,
TPlainMapping,
} from "@formbricks/types/integration/plain";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { INITIAL_MAPPING, PLAIN_FIELD_TYPES } from "../constants";
interface AddIntegrationModalProps {
environmentId: string;
surveys: TSurvey[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
plainIntegration: TIntegrationPlain;
selectedIntegration: (TIntegrationPlainConfigData & { index: number }) | null;
}
export const AddIntegrationModal = ({
environmentId,
surveys,
open,
setOpen,
plainIntegration,
selectedIntegration,
}: AddIntegrationModalProps) => {
const { t } = useTranslate();
const { handleSubmit } = useForm();
const [selectedSurvey, setSelectedSurvey] = useState<TSurvey | null>(null);
const [mapping, setMapping] = useState<
{
plainField: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
question: { id: string; name: string; type: string };
error?: {
type: string;
msg: React.ReactNode | string;
} | null;
isMandatory?: boolean;
}[]
>(INITIAL_MAPPING.map((m) => ({ ...m })));
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const [isLinkingIntegration, setIsLinkingIntegration] = useState(false);
const plainFieldTypes = PLAIN_FIELD_TYPES;
// State to track custom label ID values
const [labelIdValues, setLabelIdValues] = useState<Record<string, string>>({});
const plainIntegrationData: TIntegrationInput = {
type: "plain",
config: {
key: plainIntegration?.config?.key,
data: plainIntegration.config?.data || [],
},
};
const questionItems = useMemo(() => buildQuestionItems(selectedSurvey, t), [selectedSurvey?.id, t]);
const checkContactInfoQuestion = (survey: TSurvey | null) => {
if (!survey) return { hasContactInfo: false, missingFields: [] };
// Find ContactInfo questions in the survey
const contactInfoQuestions = survey.questions.filter(
(q) => q.type === TSurveyQuestionTypeEnum.ContactInfo
);
if (contactInfoQuestions.length === 0) {
return { hasContactInfo: false, missingFields: [] };
}
// Check if any ContactInfo question has all required fields enabled
for (const question of contactInfoQuestions) {
const contactQuestion = question as any; // Type assertion to access fields
const missingFields: string[] = [];
if (!contactQuestion.firstName?.show) {
missingFields.push("firstName");
}
if (!contactQuestion.lastName?.show) {
missingFields.push("lastName");
}
if (!contactQuestion.email?.show) {
missingFields.push("email");
}
// If this question has all required fields, return success
if (missingFields.length === 0) {
return {
hasContactInfo: true,
missingFields: [],
questionId: question.id,
question: contactQuestion,
};
}
// Otherwise continue checking other questions
}
// If we get here, we found ContactInfo questions but none with all required fields
return {
hasContactInfo: true,
missingFields: ["firstName", "lastName", "email"],
partialMatch: true,
};
};
useEffect(() => {
if (selectedIntegration) {
setSelectedSurvey(
surveys.find((survey) => {
return survey.id === selectedIntegration.surveyId;
})!
);
// Ensure mandatory fields remain protected from deletion when editing
setMapping(
selectedIntegration.mapping.map((m) => ({
...m,
// Re-apply mandatory flag based on field id
isMandatory: m.plainField.id === "threadTitle" || m.plainField.id === "componentText",
}))
);
// Initialize labelIdValues from existing mapping
const newLabelIdValues: Record<string, string> = {};
selectedIntegration.mapping.forEach((m, idx) => {
if (m.plainField.id === "labelTypeId") {
newLabelIdValues[idx] = m.question.id;
}
});
setLabelIdValues(newLabelIdValues);
return;
}
resetForm();
}, [selectedIntegration, surveys]);
// State to track contact info validation results
const [contactInfoValidation, setContactInfoValidation] = useState<{
hasContactInfo: boolean;
missingFields: string[];
partialMatch?: boolean;
questionId?: string;
question?: any;
}>({ hasContactInfo: false, missingFields: [] });
// Check for ContactInfo question when survey is selected
useEffect(() => {
if (selectedSurvey) {
const contactCheck = checkContactInfoQuestion(selectedSurvey);
setContactInfoValidation(contactCheck);
} else {
setContactInfoValidation({ hasContactInfo: false, missingFields: [] });
}
}, [selectedSurvey]);
const linkIntegration = async () => {
try {
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
}
const contactCheck = checkContactInfoQuestion(selectedSurvey);
if (!contactCheck.hasContactInfo) {
toast.error(t("environments.integrations.plain.no_contact_info_question"));
return;
} else if (contactCheck.partialMatch || contactCheck.missingFields.length > 0) {
const missingFieldsFormatted = contactCheck.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ");
toast.error(
`${t("environments.integrations.plain.contact_info_missing_fields")} ${missingFieldsFormatted}.`
);
return;
}
if (mapping.length === 0 || (mapping.length === 1 && !mapping[0].question.id)) {
throw new Error(t("environments.integrations.plain.please_select_at_least_one_mapping"));
}
if (mapping.filter((m) => m.error).length > 0) {
throw new Error(t("environments.integrations.plain.please_resolve_mapping_errors"));
}
if (mapping.filter((m) => !m.question.id).length >= 1) {
throw new Error(t("environments.integrations.plain.please_complete_mapping_fields"));
}
setIsLinkingIntegration(true);
// Find Label ID mapping if it exists
const labelIdMapping = mapping.find((m) => m.plainField.id === "labelTypeId");
const labelId = labelIdMapping?.question.id || "";
const integrationData: TIntegrationPlainConfigData = {
surveyId: selectedSurvey.id,
surveyName: selectedSurvey.name,
mapping: mapping.map((m) => {
const { error, ...rest } = m;
return rest as TPlainMapping;
}),
includeCreatedAt: true,
includeComponents: true,
labelId: labelId, // Add the Label ID from the mapping
createdAt: new Date(),
};
if (selectedIntegration) {
// update action
plainIntegrationData.config.data[selectedIntegration.index] = integrationData;
} else {
// create action
plainIntegrationData.config.data.push(integrationData);
}
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
} catch (e) {
toast.error(e.message);
} finally {
setIsLinkingIntegration(false);
}
};
const deleteLink = async () => {
plainIntegrationData.config.data.splice(selectedIntegration!.index, 1);
try {
setIsDeleting(true);
await createOrUpdateIntegrationAction({ environmentId, integrationData: plainIntegrationData });
toast.success(t("environments.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error.message);
} finally {
setIsDeleting(false);
}
};
const resetForm = () => {
setIsLinkingIntegration(false);
setSelectedSurvey(null);
setLabelIdValues({});
setMapping(INITIAL_MAPPING.map((m) => ({ ...m })));
};
const getFilteredQuestionItems = (selectedIdx) => {
const selectedQuestionIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.question.id);
return questionItems.filter((q) => !selectedQuestionIds.includes(q.id));
};
const createCopy = (item) => structuredClone(item);
const getFilteredPlainFieldTypes = (selectedIdx: number) => {
const selectedPlainFieldIds = mapping.filter((_, idx) => idx !== selectedIdx).map((m) => m.plainField.id);
return plainFieldTypes.filter((field) => !selectedPlainFieldIds.includes(field.id));
};
const MappingRow = ({ idx }: { idx: number }) => {
const filteredQuestionItems = getFilteredQuestionItems(idx);
const filteredPlainFields = getFilteredPlainFieldTypes(idx);
const addRow = () => {
const usedFieldIds = mapping.map((m) => m.plainField.id);
const availableField = plainFieldTypes.find((field) => !usedFieldIds.includes(field.id)) || {
id: "threadField",
name: "Thread Field",
type: "threadField" as TPlainFieldType,
};
setMapping((prev) => [
...prev,
{
plainField: availableField,
question: { id: "", name: "", type: "" },
isMandatory: false,
},
]);
};
const deleteRow = () => {
if (mapping[idx].isMandatory) return;
setMapping((prev) => {
return prev.filter((_, i) => i !== idx);
});
};
interface ErrorMsgProps {
error:
| {
type: string;
msg: React.ReactNode | string;
}
| null
| undefined;
field?: { id: string; name: string; type: TPlainFieldType; config?: Record<string, any> };
ques?: { id: string; name: string; type: string };
}
const ErrorMsg = ({ error }: ErrorMsgProps) => {
if (!error) return null;
return (
<div className="my-4 w-full rounded-lg bg-red-100 p-4 text-sm text-red-800">
<span className="mb-2 block">{error.type}</span>
{error.msg}
</div>
);
};
return (
<div className="w-full">
<ErrorMsg
key={idx}
error={mapping[idx]?.error}
field={mapping[idx].plainField}
ques={mapping[idx].question}
/>
<div className="flex w-full items-center space-x-2">
<div className="flex w-full items-center">
{mapping[idx].plainField.id === "labelTypeId" ? (
<div className="max-w-full flex-1">
<input
type="text"
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-slate-500 focus:outline-none focus:ring-2 focus:ring-slate-200"
placeholder={t("environments.integrations.plain.enter_label_id")}
value={labelIdValues[idx] || ""}
onChange={(e) => {
setLabelIdValues((prev) => ({
...prev,
[idx]: e.target.value,
}));
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: {
id: e.target.value,
name: "Label ID",
type: "labelTypeId",
},
error: null,
};
return copy;
});
}}
/>
</div>
) : (
// Regular question dropdown for non-Label ID fields
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_survey_question")}
items={filteredQuestionItems}
selectedItem={mapping?.[idx]?.question}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
question: item,
error: null,
};
return copy;
});
}}
disabled={questionItems.length === 0}
/>
</div>
)}
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.plain.select_a_field_to_map")}
items={filteredPlainFields}
selectedItem={mapping?.[idx]?.plainField}
disabled={filteredPlainFields.length === 0}
setSelectedItem={(item) => {
setMapping((prev) => {
const copy = createCopy(prev);
copy[idx] = {
...copy[idx],
plainField: item,
error: null,
};
return copy;
});
}}
/>
</div>
</div>
<div className="flex space-x-2">
{!mapping[idx].isMandatory && (
<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>
</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={PlainLogo}
alt={t("environments.integrations.plain.plain_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.plain.configure_plain_integration")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.plain.plain_integration_description")}
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit(linkIntegration)} className="contents space-y-4">
<DialogBody>
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<DropdownSelector
label={t("common.select_survey")}
items={surveys}
selectedItem={selectedSurvey}
setSelectedItem={setSelectedSurvey}
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
</p>
{/* Contact Info Validation Alerts */}
{selectedSurvey && (
<>
{/* Success all required fields present */}
{contactInfoValidation.hasContactInfo &&
contactInfoValidation.missingFields.length === 0 && (
<div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_success_title", {
defaultValue: "Contact-Info question found",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_all_present", {
defaultValue:
"This survey contains a complete Contact-Info question (first name, last name & email).",
})}
</p>
</div>
)}
{/* Error no contact info question */}
{!contactInfoValidation.hasContactInfo && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_missing_title", {
defaultValue: "No Contact-Info question",
})}
</p>
<p className="mt-1">
{t("environments.integrations.plain.no_contact_info_question", {
defaultValue:
"This survey does not include a Contact-Info question. Please add one with first name, last name and email enabled to use Plain.",
})}
</p>
<a
href="https://formbricks.com/docs/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
{/* Warning partial match (retain existing implementation) */}
{contactInfoValidation.hasContactInfo && contactInfoValidation.partialMatch && (
<div className="mt-2 rounded-md bg-red-50 p-3 text-sm text-red-800">
<p className="font-medium">
{t("environments.integrations.plain.contact_info_warning")}
</p>
<p className="mt-1">
{t("environments.integrations.plain.contact_info_missing_fields_description")}:{" "}
{contactInfoValidation.missingFields
.map((field) => {
switch (field) {
case "firstName":
return t("common.first_name");
case "lastName":
return t("common.last_name");
case "email":
return t("common.email");
default:
return field;
}
})
.join(", ")}
</p>
<a
href="https://docs.formbricks.com/integrations/plain#contact-info"
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block text-xs font-medium underline">
{t("common.learn_more", { defaultValue: "Learn more" })}
</a>
</div>
)}
</>
)}
</div>
{selectedSurvey && (
<div className="space-y-4">
<div>
<Label>{t("environments.integrations.plain.map_formbricks_fields_to_plain")}</Label>
<p className="mt-1 text-xs text-slate-500">
{t("environments.integrations.plain.mandatory_mapping_note", {
defaultValue:
"Thread Title and Component Text are mandatory mappings and cannot be removed.",
})}
</p>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} />
))}
</div>
</div>
</div>
)}
</div>
</div>
</DialogBody>
<DialogFooter>
{selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingIntegration}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.plain.connect")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,71 @@
import { AddKeyModal } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/AddKeyModal";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
import { connectPlainIntegrationAction } from "../actions";
vi.mock("../actions", () => ({
connectPlainIntegrationAction: vi.fn(),
}));
vi.mock("react-hot-toast");
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("AddKeyModal", () => {
const environmentId = "test-environment-id";
const setOpen = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("should disable the connect button when the API key is empty", () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).toBeDisabled();
});
test("should enable the connect button when the API key is not empty", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
expect(connectButton).not.toBeDisabled();
});
test("should call the connect action and show a success toast on successful connection", async () => {
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(connectPlainIntegrationAction).toHaveBeenCalledWith({
environmentId,
key: "test-api-key",
});
expect(toast.success).toHaveBeenCalledWith("environments.integrations.plain.connection_success");
expect(setOpen).toHaveBeenCalledWith(false);
});
});
test("should show an error toast on a failed connection", async () => {
(connectPlainIntegrationAction as Mock).mockRejectedValue(new Error("Connection error"));
render(<AddKeyModal environmentId={environmentId} open={true} setOpen={setOpen} />);
const apiKeyInput = screen.getByLabelText("environments.integrations.plain.api_key_label");
await userEvent.type(apiKeyInput, "test-api-key", { pointerEventsCheck: 0 });
const connectButton = screen.getByRole("button", { name: "common.connect" });
await userEvent.click(connectButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("environments.integrations.plain.connection_error");
});
});
});

View File

@@ -0,0 +1,91 @@
"use client";
import { Button } from "@/modules/ui/components/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
import { useTranslate } from "@tolgee/react";
import { KeyIcon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { connectPlainIntegrationAction } from "../actions";
interface AddKeyModalProps {
environmentId: string;
open?: boolean;
setOpen?: (open: boolean) => void;
}
export const AddKeyModal = ({
environmentId,
open: externalOpen,
setOpen: externalSetOpen,
}: AddKeyModalProps) => {
const { t } = useTranslate();
const [internalOpen, setInternalOpen] = useState(false);
const [keyLabel, setKeyLabel] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const open = externalOpen ?? internalOpen;
const setOpen = externalSetOpen || setInternalOpen;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center">
<div className="mr-1.5 h-6 w-6 text-slate-500">
<KeyIcon className="h-5 w-5" />
</div>
<div>
<span className="font-medium">{t("environments.integrations.plain.add_key")}</span>
<p className="text-sm font-normal text-slate-500">
{t("environments.integrations.plain.add_key_description")}
</p>
</div>
</DialogTitle>
</DialogHeader>
<div>
<div className="mb-4">
<label htmlFor="keyLabel" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.integrations.plain.api_key_label")}
</label>
<Input
id="keyLabel"
name="keyLabel"
placeholder="plainApiKey_123"
value={keyLabel}
onChange={(e) => setKeyLabel(e.target.value)}
className="w-full"
/>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button
variant="default"
disabled={!keyLabel.trim() || isSubmitting}
onClick={async () => {
try {
setIsSubmitting(true);
await connectPlainIntegrationAction({
environmentId,
key: keyLabel.trim(),
});
toast.success(t("environments.integrations.plain.connection_success"));
setOpen(false);
} catch {
toast.error(t("environments.integrations.plain.connection_error"));
} finally {
setIsSubmitting(false);
}
}}>
{isSubmitting ? <LoadingSpinner className="h-4 w-4" /> : t("common.connect")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,186 @@
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { ManageIntegration } from "./ManageIntegration";
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
deleteIntegrationAction: vi.fn(),
}));
vi.mock("@/lib/time", () => ({
timeSince: vi.fn((time) => `mock-time-since-${time}`),
}));
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn((err) => err?.message || "An error occurred"),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, onDelete, text, isDeleting }) =>
open ? (
<div>
<span>{text}</span>
<button onClick={() => onDelete()}>{isDeleting ? "Deleting..." : "Delete"}</button>
<button onClick={() => setOpen(false)}>Cancel</button>
</div>
) : null,
}));
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
EmptySpaceFiller: ({ emptyMessage }) => <div>{emptyMessage}</div>,
}));
vi.mock("@/lib/constants", () => {
const base = {
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
IS_DEVELOPMENT: true,
E2E_TESTING: false,
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
ENTERPRISE_LICENSE_KEY: undefined,
POSTHOG_API_KEY: undefined,
POSTHOG_HOST: undefined,
IS_POSTHOG_CONFIGURED: false,
GITHUB_ID: undefined,
GITHUB_SECRET: undefined,
GOOGLE_CLIENT_ID: undefined,
GOOGLE_CLIENT_SECRET: undefined,
AZUREAD_CLIENT_ID: undefined,
AZUREAD_CLIENT_SECRET: undefined,
AZUREAD_TENANT_ID: undefined,
OIDC_DISPLAY_NAME: undefined,
OIDC_CLIENT_ID: undefined,
OIDC_ISSUER: undefined,
OIDC_CLIENT_SECRET: undefined,
OIDC_SIGNING_ALGORITHM: undefined,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
WEBAPP_URL: undefined,
SENTRY_DSN: undefined,
SENTRY_RELEASE: undefined,
SENTRY_ENVIRONMENT: undefined,
};
return new Proxy(base, {
get(target, prop) {
return prop in target ? target[prop as keyof typeof target] : undefined;
},
});
});
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockEnvironment = { id: "test-env-id" } as TEnvironment;
const mockIntegrationData: TIntegrationPlainConfigData[] = [
{
surveyId: "survey-1",
surveyName: "Survey One",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
{
surveyId: "survey-2",
surveyName: "Survey Two",
createdAt: new Date(),
mapping: [],
includeMetadata: true,
includeHiddenFields: true,
includeComponents: false,
},
];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: mockIntegrationData,
},
};
describe("ManageIntegration", () => {
let setOpenAddIntegrationModal: (isOpen: boolean) => void;
let setIsConnected: (isConnected: boolean) => void;
let setSelectedIntegration: (integration: (TIntegrationPlainConfigData & { index: number }) | null) => void;
beforeEach(() => {
setOpenAddIntegrationModal = vi.fn();
setIsConnected = vi.fn();
setSelectedIntegration = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
test("renders empty state when no integrations are configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={{ ...mockPlainIntegration, config: { ...mockPlainIntegration.config, data: [] } }}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getByText("environments.integrations.plain.no_databases_found")).toBeInTheDocument();
});
test("renders a list of integrations when configured", () => {
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
expect(screen.getAllByText("Survey One")[0]).toBeInTheDocument();
expect(screen.getAllByText("Survey Two")[0]).toBeInTheDocument();
});
test("handles successful deletion of an integration", async () => {
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: mockPlainIntegration });
render(
<ManageIntegration
environment={mockEnvironment}
plainIntegration={mockPlainIntegration}
setOpenAddIntegrationModal={setOpenAddIntegrationModal}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={"en-US"}
/>
);
await userEvent.click(screen.getAllByText("environments.integrations.delete_integration")[0]);
expect(screen.getByText("environments.integrations.delete_integration_confirmation")).toBeInTheDocument();
await userEvent.click(screen.getByText("Delete"));
await waitFor(() => {
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: mockPlainIntegration.id });
});
});
});

View File

@@ -0,0 +1,133 @@
"use client";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { useTranslate } from "@tolgee/react";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import React, { useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TUserLocale } from "@formbricks/types/user";
import { IntegrationListPanel } from "../../components/IntegrationListPanel";
import { AddKeyModal } from "./AddKeyModal";
interface ManageIntegrationProps {
environment: TEnvironment;
plainIntegration: TIntegrationPlain;
setOpenAddIntegrationModal: React.Dispatch<React.SetStateAction<boolean>>;
setIsConnected: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedIntegration: React.Dispatch<
React.SetStateAction<(TIntegrationPlainConfigData & { index: number }) | null>
>;
locale: TUserLocale;
}
export const ManageIntegration = ({
environment,
plainIntegration,
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslate();
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [isKeyModalOpen, setIsKeyModalOpen] = useState(false);
const [isDeleting, setisDeleting] = useState(false);
let integrationArray: TIntegrationPlainConfigData[] = [];
if (plainIntegration?.config.data) {
integrationArray = plainIntegration.config.data;
}
const handleDeleteIntegration = async () => {
setisDeleting(true);
const deleteIntegrationActionResult = await deleteIntegrationAction({
integrationId: plainIntegration.id,
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
toast.error(errorMessage);
}
setisDeleting(false);
setIsDeleteIntegrationModalOpen(false);
};
const editIntegration = (index: number) => {
setSelectedIntegration({ ...plainIntegration.config.data[index], index });
setOpenAddIntegrationModal(true);
};
return (
<>
<IntegrationListPanel
environment={environment}
statusNode={
<>
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">{t("common.connected")}</span>
</>
}
reconnectAction={{
label: t("environments.integrations.plain.update_connection"),
onClick: () => setIsKeyModalOpen(true),
icon: <RefreshCcwIcon className="mr-2 h-4 w-4" />,
tooltip: t("environments.integrations.plain.update_connection_tooltip"),
variant: "outline",
}}
addNewAction={{
label: t("environments.integrations.plain.link_new_database"),
onClick: () => {
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
},
}}
emptyMessage={t("environments.integrations.plain.no_databases_found")}
items={integrationArray}
columns={[
{
header: t("common.survey"),
render: (item: TIntegrationPlainConfigData) => item.surveyName,
},
{
header: t("common.survey_id"),
render: (item: TIntegrationPlainConfigData) => item.surveyId,
},
{
header: t("common.updated_at"),
render: (item: TIntegrationPlainConfigData) => timeSince(item.createdAt.toString(), locale),
},
]}
onRowClick={editIntegration}
getRowKey={(item: TIntegrationPlainConfigData, idx) => `${idx}-${item.surveyId}`}
/>
<div className="mt-4 flex justify-center">
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)}>
<Trash2Icon />
{t("environments.integrations.delete_integration")}
</Button>
</div>
<AddKeyModal environmentId={environment.id} open={isKeyModalOpen} setOpen={setIsKeyModalOpen} />
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.plain.plain_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</>
);
};

View File

@@ -0,0 +1,81 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { PlainWrapper } from "./PlainWrapper";
// Mock child components
vi.mock("@/modules/ui/components/connect-integration", () => ({
ConnectIntegration: vi.fn(() => <div>Mocked ConnectIntegration</div>),
}));
vi.mock("./AddIntegrationModal", () => ({
AddIntegrationModal: vi.fn(() => <div>Mocked AddIntegrationModal</div>),
}));
vi.mock("./AddKeyModal", () => ({
AddKeyModal: vi.fn(() => <div>Mocked AddKeyModal</div>),
}));
vi.mock("./ManageIntegration", () => ({
ManageIntegration: vi.fn(() => <div>Mocked ManageIntegration</div>),
}));
const mockEnvironment = {
id: "test-env-id",
name: "Test Environment",
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [];
const mockPlainIntegration: TIntegrationPlain = {
id: "integration-id",
type: "plain",
environmentId: "test-env-id",
config: {
key: "test-key",
data: [],
},
};
describe("PlainWrapper", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders ConnectIntegration when not connected", () => {
render(
<PlainWrapper
plainIntegration={undefined}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ConnectIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ManageIntegration")).not.toBeInTheDocument();
});
test("renders ManageIntegration when connected", () => {
render(
<PlainWrapper
plainIntegration={mockPlainIntegration}
enabled={true}
environment={mockEnvironment}
webAppUrl="http://localhost:3000"
surveys={mockSurveys}
databasesArray={[]}
locale="en-US"
/>
);
expect(screen.getByText("Mocked ManageIntegration")).toBeInTheDocument();
expect(screen.queryByText("Mocked ConnectIntegration")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,77 @@
"use client";
import PlainLogo from "@/images/plain.webp";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain, TIntegrationPlainConfigData } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "./AddIntegrationModal";
import { AddKeyModal } from "./AddKeyModal";
import { ManageIntegration } from "./ManageIntegration";
interface PlainWrapperProps {
plainIntegration: TIntegrationPlain | undefined;
enabled: boolean;
environment: TEnvironment;
webAppUrl: string;
surveys: TSurvey[];
databasesArray: any[];
locale: TUserLocale;
}
export const PlainWrapper = ({
plainIntegration,
enabled,
environment,
surveys,
locale,
}: PlainWrapperProps) => {
const [isModalOpen, setModalOpen] = useState(false);
const [open, setOpen] = useState(false);
const [isConnected, setIsConnected] = useState(plainIntegration ? plainIntegration.config.key : false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationPlainConfigData & { index: number }) | null
>(null);
const handlePlainAuthorization = async () => {
setOpen(true);
};
return (
<>
{isConnected && plainIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
surveys={surveys}
open={isModalOpen}
setOpen={setModalOpen}
plainIntegration={plainIntegration}
selectedIntegration={selectedIntegration}
/>
<ManageIntegration
environment={environment}
plainIntegration={plainIntegration}
setOpenAddIntegrationModal={setModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
locale={locale}
/>
</>
) : (
<>
<AddKeyModal environmentId={environment.id} open={open} setOpen={setOpen} />
<ConnectIntegration
isEnabled={enabled}
integrationType={"plain"}
handleAuthorization={handlePlainAuthorization}
integrationLogoSrc={PlainLogo}
/>
</>
)}
</>
);
};

View File

@@ -0,0 +1,24 @@
import { TPlainFieldType } from "@formbricks/types/integration/plain";
export const PLAIN_FIELD_TYPES: {
id: string;
name: string;
type: TPlainFieldType;
}[] = [
{ id: "threadTitle", name: "Thread Title", type: "threadField" as TPlainFieldType },
{ id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
{ id: "labelTypeId", name: "Label ID", type: "labelTypeId" as TPlainFieldType },
];
export const INITIAL_MAPPING = [
{
plainField: { id: "threadTitle", name: "Thread Title", type: "title" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
{
plainField: { id: "componentText", name: "Component Text", type: "componentText" as TPlainFieldType },
question: { id: "", name: "", type: "" },
isMandatory: true,
},
] as const;

View File

@@ -0,0 +1,210 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { redirect } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
import { TSurvey } from "@formbricks/types/surveys/types";
import Page from "./page";
// Mock dependencies
vi.mock("@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper", () => ({
PlainWrapper: vi.fn(
({ enabled, surveys, environment, plainIntegration, webAppUrl, databasesArray, locale }) => (
<div>
<span>Mocked PlainWrapper</span>
<span data-testid="enabled">{enabled.toString()}</span>
<span data-testid="environmentId">{environment.id}</span>
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
<span data-testid="integrationId">{plainIntegration?.id}</span>
<span data-testid="webAppUrl">{webAppUrl}</span>
<span data-testid="databasesArray">{databasesArray?.length ?? 0}</span>
<span data-testid="locale">{locale}</span>
</div>
)
),
}));
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
getSurveys: vi.fn(),
}));
vi.mock("@/lib/integration/service", () => ({
getIntegrationByType: vi.fn(),
}));
vi.mock("@/lib/utils/locale", () => ({
findMatchingLocale: vi.fn(),
}));
vi.mock("@/modules/environments/lib/utils", () => ({
getEnvironmentAuth: vi.fn(),
}));
vi.mock("@/modules/ui/components/go-back-button", () => ({
GoBackButton: vi.fn(({ url }) => <div data-testid="go-back">{url}</div>),
}));
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div>{children}</div>),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle }) => <h1>{pageTitle}</h1>),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key) => key,
}));
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "https://app.formbricks.com",
}));
const mockEnvironment = {
id: "test-env-id",
createdAt: new Date(),
updatedAt: new Date(),
appSetupCompleted: true,
type: "development",
projectId: "project-id",
project: {
id: "project-id",
name: "Test Project",
environments: [],
people: [],
surveys: [],
tags: [],
webhooks: [],
apiKey: {
id: "api-key",
createdAt: new Date(),
updatedAt: new Date(),
hashedKey: "hashed",
label: "api",
},
logo: null,
createdAt: new Date(),
updatedAt: new Date(),
organizationId: "org-id",
recontactDays: 30,
inAppSurveyBranding: false,
linkSurveyBranding: false,
placement: "bottomRight",
clickOutsideClose: true,
darkOverlay: false,
},
} as unknown as TEnvironment;
const mockSurveys: TSurvey[] = [
{
id: "survey1",
name: "Survey 1",
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "test-env-id",
status: "inProgress",
type: "app",
questions: [],
triggers: [],
recontactDays: null,
autoClose: null,
closeOnDate: null,
delay: 0,
displayOption: "displayOnce",
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
autoComplete: null,
runOnDate: null,
} as unknown as TSurvey,
];
const mockPlainIntegration = {
id: "integration1",
type: "plain",
environmentId: "test-env-id",
config: {
key: "plain-key",
data: [],
},
} as unknown as TIntegrationPlain;
const mockProps = {
params: { environmentId: "test-env-id" },
};
describe("PlainIntegrationPage", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: true,
hasManageAccess: false,
isReadOnly: false,
});
vi.mocked(getSurveys).mockResolvedValue(mockSurveys);
vi.mocked(getIntegrationByType).mockResolvedValue(mockPlainIntegration);
vi.mocked(findMatchingLocale).mockResolvedValue("en-US");
});
test("renders the page with PlainWrapper when enabled and not read-only", async () => {
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(screen.getByText("environments.integrations.plain.plain_integration")).toBeInTheDocument();
expect(screen.getByText("Mocked PlainWrapper")).toBeInTheDocument();
expect(screen.getByTestId("enabled")).toHaveTextContent("true");
expect(screen.getByTestId("environmentId")).toHaveTextContent(mockEnvironment.id);
expect(screen.getByTestId("surveyCount")).toHaveTextContent(mockSurveys.length.toString());
expect(screen.getByTestId("integrationId")).toHaveTextContent(mockPlainIntegration.id);
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("https://app.formbricks.com");
expect(screen.getByTestId("databasesArray")).toHaveTextContent("0");
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`https://app.formbricks.com/environments/${mockProps.params.environmentId}/integrations`
);
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
});
test("calls redirect when user is read-only", async () => {
vi.mocked(getEnvironmentAuth).mockResolvedValue({
environment: mockEnvironment,
project: {} as any,
organization: {} as any,
session: {} as any,
currentUserMembership: {} as any,
projectPermission: {} as any,
isMember: true,
isOwner: false,
isManager: false,
isBilling: false,
hasReadAccess: true,
hasReadWriteAccess: false,
hasManageAccess: false,
isReadOnly: true,
});
const PageComponent = await Page(mockProps);
render(PageComponent);
expect(vi.mocked(redirect)).toHaveBeenCalledWith("./");
});
});

View File

@@ -0,0 +1,49 @@
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
import { PlainWrapper } from "@/app/(app)/environments/[environmentId]/integrations/plain/components/PlainWrapper";
import { WEBAPP_URL } from "@/lib/constants";
import { getIntegrationByType } from "@/lib/integration/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { GoBackButton } from "@/modules/ui/components/go-back-button";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
import { redirect } from "next/navigation";
import { TIntegrationPlain } from "@formbricks/types/integration/plain";
const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
const [surveys, plainIntegration] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "plain"),
]);
const databasesArray = [];
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.plain.plain_integration") || "Plain Integration"} />
<PlainWrapper
enabled={true}
surveys={surveys}
environment={environment}
plainIntegration={plainIntegration as TIntegrationPlain}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
locale={locale}
/>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -1,3 +1,4 @@
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";
@@ -5,6 +6,7 @@ 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";
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
// Mock sub-components to render identifiable elements
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
EnvironmentLayout: ({ children }: any) => <div data-testid="EnvironmentLayout">{children}</div>,
EnvironmentLayout: ({ children, environmentId, session }: any) => (
<div data-testid="EnvironmentLayout" data-environment-id={environmentId} data-session={session?.user?.id}>
{children}
</div>
),
}));
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
<div data-testid="EnvironmentIdBaseLayout">
{environmentId}
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}>
{children}
</div>
),
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
ToasterClient: () => <div data-testid="ToasterClient" />,
}));
vi.mock("./components/EnvironmentStorageHandler", () => ({
default: ({ environmentId }: any) => <div data-testid="EnvironmentStorageHandler">{environmentId}</div>,
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(),
}));
// Mocks for dependencies
@@ -37,26 +64,43 @@ 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: ((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,
t: mockTranslation,
session: mockSession,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
id: "member1",
} as unknown as TMembership);
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
const result = await EnvLayout({
params: Promise.resolve({ environmentId: "env1" }),
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
});
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
// 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("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("throws error if project is not found", async () => {
test("redirects when session is null", 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(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,
t: mockTranslation,
session: null as unknown as Session,
user: mockUser,
organization: mockOrganization,
});
vi.mocked(redirect).mockImplementationOnce(() => {
throw new Error("Redirect called");
@@ -125,18 +156,16 @@ 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: ((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");
t: mockTranslation,
session: mockSession,
user: null as unknown as TUser,
organization: mockOrganization,
});
await expect(
@@ -145,5 +174,154 @@ 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,4 +1,6 @@
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";
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
@@ -24,11 +25,19 @@ const EnvLayout = async (props: {
throw new Error(t("common.user_not_found"));
}
const project = await getProjectByEnvironmentId(params.environmentId);
const [project, environment] = await Promise.all([
getProjectByEnvironmentId(params.environmentId),
getEnvironment(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) {
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
user={user}
organization={organization}>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
<EnvironmentContextWrapper environment={environment} project={project}>
<EnvironmentLayout environmentId={params.environmentId} session={session}>
{children}
</EnvironmentLayout>
</EnvironmentContextWrapper>
</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: "test-redis-url",
REDIS_URL: undefined,
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: "redis://localhost:6379",
REDIS_URL: undefined,
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: "redis://localhost:6379",
REDIS_URL: undefined,
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: "redis://localhost:6379",
REDIS_URL: undefined,
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: "redis://localhost:6379",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
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: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -169,7 +169,7 @@ export const resetPasswordAction = authenticatedActionClient.action(
"user",
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
if (ctx.user.identityProvider !== "email") {
throw new OperationNotAllowedError("auth.reset-password.not-allowed");
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
}
await sendForgotPasswordEmail(ctx.user);

View File

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

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: "redis://localhost:6379",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -2,6 +2,7 @@
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 = ({
@@ -31,7 +32,7 @@ export const SettingsCard = ({
id={title}>
<div className="border-b border-slate-200 px-4 pb-4">
<div className="flex">
<h3 className="text-lg font-medium capitalize leading-6 text-slate-900">{title}</h3>
<H3 className="capitalize">{title}</H3>
<div className="ml-2">
{beta && <Badge size="normal" type="warning" text="Beta" />}
{soon && (
@@ -39,7 +40,9 @@ export const SettingsCard = ({
)}
</div>
</div>
<p className="mt-1 text-sm text-slate-500">{description}</p>
<Small color="muted" margin="headerDescription">
{description}
</Small>
</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: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -313,3 +313,42 @@ export const generatePersonalLinksAction = authenticatedActionClient
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

@@ -1,307 +0,0 @@
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),
}));
// Mock getSurveyUrl to return a predictable URL
vi.mock("@/modules/analysis/utils", () => ({
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
}));
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} />;
},
};
});
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,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: true,
};
beforeEach(() => {
mockEmbedViewComponent.mockImplementation(
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
<div>
<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>
)
);
});
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.getByTestId("embedview-tabs")).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);
// Panel view currently just shows a title, no component is rendered
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
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(4);
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.getByTestId("embedview-tabs")).toBeInTheDocument();
cleanup();
render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="panel" />);
// Panel view currently just shows a title
expect(screen.getByText("environments.surveys.summary.send_to_panel")).toBeInTheDocument();
});
test("useEffect sets showView to 'start' when open becomes false", () => {
const { rerender } = render(<ShareEmbedSurvey {...defaultProps} open={true} modalView="embed" />);
expect(screen.getByTestId("embedview-tabs")).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.queryByTestId("embedview-tabs")).not.toBeInTheDocument();
});
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

@@ -1,199 +0,0 @@
"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,
UserIcon,
UsersRound,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
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 { EmbedView } from "./shareEmbedModal/EmbedView";
interface ShareEmbedSurveyProps {
survey: TSurvey;
publicDomain: string;
open: boolean;
modalView: "start" | "embed" | "panel";
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
user: TUser;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const ShareEmbedSurvey = ({
survey,
publicDomain,
open,
modalView,
setOpen,
user,
segments,
isContactsEnabled,
isFormbricksCloud,
}: 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: "personal-links", label: t("environments.surveys.summary.personal_links"), icon: UserIcon },
{ 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[4].id);
const [showView, setShowView] = useState<"start" | "embed" | "panel" | "personal-links">("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[4].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[4].id);
setOpen(open);
if (!open) {
setShowView("start");
}
router.refresh();
};
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" ? (
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.embed_survey")}</DialogTitle>
<EmbedView
tabs={survey.type === "link" ? tabs : [tabs[4]]}
activeId={activeId}
environmentId={environmentId}
setActiveId={setActiveId}
survey={survey}
email={email}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={user.locale}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
</>
) : showView === "panel" ? (
<>
<DialogTitle className="sr-only">{t("environments.surveys.summary.send_to_panel")}</DialogTitle>
</>
) : null}
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,5 @@
"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";
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
)}
</span>
{!isLoading && (
<Button variant="secondary" className="h-6 w-6">
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
</div>

View File

@@ -1,10 +1,11 @@
"use client";
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";
@@ -12,7 +13,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
@@ -32,10 +33,8 @@ interface SurveyAnalysisCTAProps {
}
interface ModalState {
start: boolean;
share: boolean;
embed: boolean;
panel: boolean;
dropdown: boolean;
}
export const SurveyAnalysisCTA = ({
@@ -50,38 +49,39 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
share: searchParams.get("share") === "true",
embed: false,
panel: false,
dropdown: false,
start: searchParams.get("share") === "true",
share: false,
});
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const { refreshSingleUseId } = useSingleUseId(survey);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
useEffect(() => {
setModalState((prev) => ({
...prev,
share: searchParams.get("share") === "true",
start: searchParams.get("share") === "true",
}));
}, [searchParams]);
const handleShareModalToggle = (open: boolean) => {
const params = new URLSearchParams(window.location.search);
if (open) {
const currentShareParam = params.get("share") === "true";
if (open && !currentShareParam) {
params.set("share", "true");
} else {
router.push(`${pathname}?${params.toString()}`);
} else if (!open && currentShareParam) {
params.delete("share");
router.push(`${pathname}?${params.toString()}`);
}
router.push(`${pathname}?${params.toString()}`);
setModalState((prev) => ({ ...prev, share: open }));
setModalState((prev) => ({ ...prev, start: open }));
};
const duplicateSurveyAndRoute = async (surveyId: string) => {
@@ -102,23 +102,19 @@ export const SurveyAnalysisCTA = ({
setLoading(false);
};
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
};
const getPreviewUrl = async () => {
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
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 }));
};
};
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
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") },
];
surveyUrl.searchParams.set("preview", "true");
return surveyUrl.toString();
};
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
@@ -132,7 +128,10 @@ export const SurveyAnalysisCTA = ({
{
icon: Eye,
tooltip: t("common.preview"),
onClick: () => window.open(getPreviewUrl(), "_blank"),
onClick: async () => {
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
isVisible: survey.type === "link",
},
{
@@ -164,32 +163,31 @@ export const SurveyAnalysisCTA = ({
<IconBar actions={iconActions} />
<Button
className="h-10"
onClick={() => {
setModalState((prev) => ({ ...prev, embed: true }));
setModalState((prev) => ({ ...prev, share: true }));
}}>
{t("environments.surveys.summary.share_survey")}
</Button>
{user && (
<>
{shareEmbedViews.map(({ key, modalView, setOpen }) => (
<ShareEmbedSurvey
key={key}
survey={survey}
publicDomain={publicDomain}
open={modalState[key as keyof ModalState]}
setOpen={setOpen}
user={user}
modalView={modalView}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
))}
<SuccessMessage environment={environment} survey={survey} />
</>
<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}
/>
)}
<SuccessMessage environment={environment} survey={survey} />
{responseCount > 0 && (
<EditPublicSurveyAlertDialog

View File

@@ -0,0 +1,473 @@
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

@@ -0,0 +1,228 @@
"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

@@ -1,63 +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 { 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

@@ -1,27 +0,0 @@
"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

@@ -1,133 +0,0 @@
"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

@@ -1,181 +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 { 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>
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { segments: any[]; surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string; buttons: any[] }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</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>,
AlertCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle">
AlertCircle
</div>
),
AlertTriangle: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle">
AlertTriangle
</div>
),
Info: ({ className }: { className?: string }) => (
<div className={className} data-testid="info">
Info
</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 = {
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,
segments: [],
isContactsEnabled: true,
isFormbricksCloud: false,
};
describe("EmbedView", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
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

@@ -1,125 +0,0 @@
"use client";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { TSegment } from "@formbricks/types/segment";
import { TUserLocale } from "@formbricks/types/user";
import { AppTab } from "./AppTab";
import { EmailTab } from "./EmailTab";
import { LinkTab } from "./LinkTab";
import { WebsiteTab } from "./WebsiteTab";
import { PersonalLinksTab } from "./personal-links-tab";
interface EmbedViewProps {
tabs: Array<{ id: string; label: string; icon: any }>;
activeId: string;
setActiveId: React.Dispatch<React.SetStateAction<string>>;
environmentId: string;
survey: any;
email: string;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: React.Dispatch<React.SetStateAction<string>>;
locale: TUserLocale;
segments: TSegment[];
isContactsEnabled: boolean;
isFormbricksCloud: boolean;
}
export const EmbedView = ({
tabs,
activeId,
setActiveId,
environmentId,
survey,
email,
surveyUrl,
publicDomain,
setSurveyUrl,
locale,
segments,
isContactsEnabled,
isFormbricksCloud,
}: EmbedViewProps) => {
const renderActiveTab = () => {
switch (activeId) {
case "email":
return <EmailTab surveyId={survey.id} email={email} />;
case "webpage":
return <WebsiteTab surveyUrl={surveyUrl} environmentId={environmentId} />;
case "link":
return (
<LinkTab
survey={survey}
surveyUrl={surveyUrl}
publicDomain={publicDomain}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
case "app":
return <AppTab />;
case "personal-links":
return (
<PersonalLinksTab
segments={segments}
surveyId={survey.id}
environmentId={environmentId}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={isFormbricksCloud}
/>
);
default:
return null;
}
};
return (
<div className="h-full overflow-hidden">
<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`}>
{renderActiveTab()}
<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

@@ -1,155 +0,0 @@
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

@@ -1,72 +0,0 @@
"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

@@ -1,69 +0,0 @@
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

@@ -1,25 +0,0 @@
"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

@@ -1,53 +0,0 @@
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

@@ -1,25 +0,0 @@
"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

@@ -1,254 +0,0 @@
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

@@ -1,118 +0,0 @@
"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

@@ -0,0 +1,433 @@
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

@@ -0,0 +1,361 @@
"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

@@ -0,0 +1,383 @@
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

@@ -0,0 +1,238 @@
"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

@@ -0,0 +1,95 @@
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

@@ -0,0 +1,71 @@
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
} 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 className="text-sm font-medium text-slate-900">
{type === "multi-use"
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
: t("common.are_you_sure")}
</DialogHeader>
<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

@@ -0,0 +1,38 @@
"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

@@ -0,0 +1,102 @@
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

@@ -0,0 +1,37 @@
"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>
);
};

View File

@@ -0,0 +1,165 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DocumentationLinksSection } from "./documentation-links-section";
// Mock the useTranslate hook
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
if (key === "common.read_docs") {
return "Read docs";
}
return key;
},
}),
}));
// Mock Next.js Link component
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock Alert components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, size, variant }: any) => (
<div data-testid="alert" data-size={size} data-variant={variant}>
{children}
</div>
),
AlertButton: ({ children }: any) => <div data-testid="alert-button">{children}</div>,
AlertTitle: ({ children }: any) => <div data-testid="alert-title">{children}</div>,
}));
// Mock Typography components
vi.mock("@/modules/ui/components/typography", () => ({
H4: ({ children }: any) => <h4 data-testid="h4">{children}</h4>,
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
ArrowUpRight: ({ className }: any) => <svg data-testid="arrow-up-right-icon" className={className} />,
}));
describe("DocumentationLinksSection", () => {
afterEach(() => {
cleanup();
});
const mockLinks = [
{
href: "https://example.com/docs/html",
title: "HTML Documentation",
},
{
href: "https://example.com/docs/react",
title: "React Documentation",
},
{
href: "https://example.com/docs/javascript",
title: "JavaScript Documentation",
},
];
test("renders title correctly", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title");
});
test("renders all documentation links", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
expect(screen.getAllByTestId("alert")).toHaveLength(3);
expect(screen.getByText("HTML Documentation")).toBeInTheDocument();
expect(screen.getByText("React Documentation")).toBeInTheDocument();
expect(screen.getByText("JavaScript Documentation")).toBeInTheDocument();
});
test("renders links with correct href attributes", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const links = screen.getAllByRole("link");
expect(links[0]).toHaveAttribute("href", "https://example.com/docs/html");
expect(links[1]).toHaveAttribute("href", "https://example.com/docs/react");
expect(links[2]).toHaveAttribute("href", "https://example.com/docs/javascript");
});
test("renders links with correct target and rel attributes", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const links = screen.getAllByRole("link");
links.forEach((link) => {
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
});
test("renders read docs button for each link", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const readDocsButtons = screen.getAllByText("Read docs");
expect(readDocsButtons).toHaveLength(3);
});
test("renders icons for each alert", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const icons = screen.getAllByTestId("arrow-up-right-icon");
expect(icons).toHaveLength(3);
});
test("renders alerts with correct props", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={mockLinks} />);
const alerts = screen.getAllByTestId("alert");
alerts.forEach((alert) => {
expect(alert).toHaveAttribute("data-size", "small");
expect(alert).toHaveAttribute("data-variant", "default");
});
});
test("renders with empty links array", () => {
render(<DocumentationLinksSection title="Test Documentation Title" links={[]} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Test Documentation Title");
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
test("renders single link correctly", () => {
const singleLink = [
{
href: "https://example.com/docs/single",
title: "Single Documentation",
},
];
render(<DocumentationLinksSection title="Test Documentation Title" links={singleLink} />);
expect(screen.getAllByTestId("alert")).toHaveLength(1);
expect(screen.getByText("Single Documentation")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.com/docs/single");
});
test("renders with special characters in title and links", () => {
const specialLinks = [
{
href: "https://example.com/docs/special?param=value&other=test",
title: "Special Characters & Symbols",
},
];
render(<DocumentationLinksSection title="Special Title & Characters" links={specialLinks} />);
expect(screen.getByTestId("h4")).toHaveTextContent("Special Title & Characters");
expect(screen.getByText("Special Characters & Symbols")).toBeInTheDocument();
expect(screen.getByRole("link")).toHaveAttribute(
"href",
"https://example.com/docs/special?param=value&other=test"
);
});
});

View File

@@ -0,0 +1,217 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DynamicPopupTab } from "./dynamic-popup-tab";
// Mock components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: (props: { variant?: string; size?: string; children: React.ReactNode }) => (
<div data-testid="alert" data-variant={props.variant} data-size={props.size}>
{props.children}
</div>
),
AlertButton: (props: { asChild?: boolean; children: React.ReactNode }) => (
<div data-testid="alert-button" data-as-child={props.asChild}>
{props.children}
</div>
),
AlertDescription: (props: { children: React.ReactNode }) => (
<div data-testid="alert-description">{props.children}</div>
),
AlertTitle: (props: { children: React.ReactNode }) => <div data-testid="alert-title">{props.children}</div>,
}));
// Mock DocumentationLinks
vi.mock("./documentation-links", () => ({
DocumentationLinks: (props: { links: Array<{ href: string; title: string }> }) => (
<div data-testid="documentation-links">
{props.links.map((link) => (
<div key={link.title} data-testid="documentation-link" data-href={link.href} data-title={link.title}>
{link.title}
</div>
))}
</div>
),
}));
// Mock Next.js Link
vi.mock("next/link", () => ({
default: (props: { href: string; target?: string; className?: string; children: React.ReactNode }) => (
<a href={props.href} target={props.target} className={props.className} data-testid="next-link">
{props.children}
</a>
),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
describe("DynamicPopupTab", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
environmentId: "env-123",
surveyId: "survey-123",
};
test("renders with correct container structure", () => {
render(<DynamicPopupTab {...defaultProps} />);
const container = screen.getByTestId("dynamic-popup-container");
expect(container).toHaveClass("flex", "h-full", "flex-col", "justify-between", "space-y-4");
});
test("renders alert with correct props", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alert = screen.getByTestId("alert");
expect(alert).toBeInTheDocument();
expect(alert).toHaveAttribute("data-variant", "info");
expect(alert).toHaveAttribute("data-size", "default");
});
test("renders alert title with correct translation key", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertTitle = screen.getByTestId("alert-title");
expect(alertTitle).toBeInTheDocument();
expect(alertTitle).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_title");
});
test("renders alert description with correct translation key", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertDescription = screen.getByTestId("alert-description");
expect(alertDescription).toBeInTheDocument();
expect(alertDescription).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_description");
});
test("renders alert button with link to survey edit page", () => {
render(<DynamicPopupTab {...defaultProps} />);
const alertButton = screen.getByTestId("alert-button");
expect(alertButton).toBeInTheDocument();
expect(alertButton).toHaveAttribute("data-as-child", "true");
const link = screen.getByTestId("next-link");
expect(link).toHaveAttribute("href", "/environments/env-123/surveys/survey-123/edit");
expect(link).toHaveTextContent("environments.surveys.share.dynamic_popup.alert_button");
});
test("renders DocumentationLinks component", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getByTestId("documentation-links");
expect(documentationLinks).toBeInTheDocument();
});
test("passes correct documentation links to DocumentationLinks component", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
expect(documentationLinks).toHaveLength(3);
// Check attribute-based targeting link
const attributeLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting"
);
expect(attributeLink).toBeInTheDocument();
expect(attributeLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.attribute_based_targeting"
);
// Check code and no code triggers link
const actionsLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
);
expect(actionsLink).toBeInTheDocument();
expect(actionsLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.code_no_code_triggers"
);
// Check recontact options link
const recontactLink = documentationLinks.find(
(link) =>
link.getAttribute("data-href") ===
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact"
);
expect(recontactLink).toBeInTheDocument();
expect(recontactLink).toHaveAttribute(
"data-title",
"environments.surveys.share.dynamic_popup.recontact_options"
);
});
test("renders documentation links with correct titles", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
const expectedTitles = [
"environments.surveys.share.dynamic_popup.attribute_based_targeting",
"environments.surveys.share.dynamic_popup.code_no_code_triggers",
"environments.surveys.share.dynamic_popup.recontact_options",
];
expectedTitles.forEach((title) => {
const link = documentationLinks.find((link) => link.getAttribute("data-title") === title);
expect(link).toBeInTheDocument();
expect(link).toHaveTextContent(title);
});
});
test("renders documentation links with correct URLs", () => {
render(<DynamicPopupTab {...defaultProps} />);
const documentationLinks = screen.getAllByTestId("documentation-link");
const expectedUrls = [
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
"https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
];
expectedUrls.forEach((url) => {
const link = documentationLinks.find((link) => link.getAttribute("data-href") === url);
expect(link).toBeInTheDocument();
});
});
test("calls translation function for all text content", () => {
render(<DynamicPopupTab {...defaultProps} />);
// Check alert translations
expect(screen.getByTestId("alert-title")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_title"
);
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_description"
);
expect(screen.getByTestId("next-link")).toHaveTextContent(
"environments.surveys.share.dynamic_popup.alert_button"
);
});
test("renders with correct props when environmentId and surveyId change", () => {
const newProps = {
environmentId: "env-456",
surveyId: "survey-456",
};
render(<DynamicPopupTab {...newProps} />);
const link = screen.getByTestId("next-link");
expect(link).toHaveAttribute("href", "/environments/env-456/surveys/survey-456/edit");
});
});

View File

@@ -0,0 +1,46 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { useTranslate } from "@tolgee/react";
import Link from "next/link";
interface DynamicPopupTabProps {
environmentId: string;
surveyId: string;
}
export const DynamicPopupTab = ({ environmentId, surveyId }: DynamicPopupTabProps) => {
const { t } = useTranslate();
return (
<div className="flex h-full flex-col justify-between space-y-4" data-testid="dynamic-popup-container">
<Alert variant="info" size="default">
<AlertTitle>{t("environments.surveys.share.dynamic_popup.alert_title")}</AlertTitle>
<AlertDescription>{t("environments.surveys.share.dynamic_popup.alert_description")}</AlertDescription>
<AlertButton asChild>
<Link href={`/environments/${environmentId}/surveys/${surveyId}/edit`}>
{t("environments.surveys.share.dynamic_popup.alert_button")}
</Link>
</AlertButton>
</Alert>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.dynamic_popup.attribute_based_targeting"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting",
},
{
title: t("environments.surveys.share.dynamic_popup.code_no_code_triggers"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions",
},
{
title: t("environments.surveys.share.dynamic_popup.recontact_options"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/recontact",
},
]}
/>
</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 "./EmailTab";
import { EmailTab } from "./email-tab";
// Mock actions
vi.mock("../../actions", () => ({
@@ -20,15 +20,23 @@ vi.mock("@/lib/utils/helper", () => ({
// Mock UI components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, title, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} {...props}>
Button: ({ children, onClick, variant, title, "aria-label": ariaLabel, ...props }: any) => (
<button onClick={onClick} data-variant={variant} title={title} aria-label={ariaLabel} {...props}>
{children}
</button>
),
}));
vi.mock("@/modules/ui/components/code-block", () => ({
CodeBlock: ({ children, language }: { children: React.ReactNode; language: string }) => (
<div data-testid="code-block" data-language={language}>
CodeBlock: ({
children,
language,
showCopyToClipboard,
}: {
children: React.ReactNode;
language: string;
showCopyToClipboard?: boolean;
}) => (
<div data-testid="code-block" data-language={language} data-show-copy={showCopyToClipboard}>
{children}
</div>
),
@@ -41,7 +49,9 @@ 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
@@ -74,22 +84,42 @@ 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.summary.view_embed_code_for_email" })
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByTestId("mail-icon")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).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
// Email preview section
await waitFor(() => {
expect(screen.getByText(`To : ${userEmail}`)).toBeInTheDocument();
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("Subject : environments.surveys.summary.formbricks_email_survey_preview")
).toBeInTheDocument();
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);
await waitFor(() => {
expect(screen.getByText("Hello World ?preview=true&foo=bar")).toBeInTheDocument(); // Raw HTML content
expect(screen.getByText("Hello World ?foo=bar")).toBeInTheDocument(); // HTML content rendered as text (preview=true removed)
});
expect(screen.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -99,32 +129,47 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
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.summary.view_embed_code_for_email" }) // Updated name for hide button
screen.getByRole("button", { name: "environments.surveys.share.send_email.copy_embed_code" })
).toBeInTheDocument();
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
const codeBlock = screen.getByTestId("code-block");
expect(codeBlock).toBeInTheDocument();
expect(codeBlock).toHaveTextContent(mockCleanedEmailHtml); // Cleaned HTML
expect(screen.queryByText(`To : ${userEmail}`)).not.toBeInTheDocument();
// Toggle back
const hideEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email", // Updated name for hide button
});
await userEvent.click(hideEmbedButton);
expect(screen.getByRole("button", { name: "send preview email" })).toBeInTheDocument();
// The email_to_label should not be visible in embed code view
expect(
screen.getByRole("button", { name: "environments.surveys.summary.view_embed_code_for_email" })
screen.queryByText((content, element) => {
return (
element?.textContent?.includes("environments.surveys.share.send_email.email_to_label") || false
);
})
).not.toBeInTheDocument();
// Toggle back to preview
const previewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.email_preview_tab",
});
await userEvent.click(previewButton);
expect(
screen.getByRole("button", { name: "environments.surveys.share.send_email.send_preview_email" })
).toBeInTheDocument();
expect(screen.getByText(`To : ${userEmail}`)).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.queryByTestId("code-block")).not.toBeInTheDocument();
});
@@ -133,16 +178,19 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);
// Ensure this line queries by the correct aria-label
const copyCodeButton = screen.getByRole("button", { name: "Embed survey in your website" });
const copyCodeButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.copy_embed_code",
});
await userEvent.click(copyCodeButton);
expect(mockWriteText).toHaveBeenCalledWith(mockCleanedEmailHtml);
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.embed_code_copied_to_clipboard");
expect(toast.success).toHaveBeenCalledWith(
"environments.surveys.share.send_email.embed_code_copied_to_clipboard"
);
});
test("sends preview email successfully", async () => {
@@ -150,11 +198,13 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
expect(toast.success).toHaveBeenCalledWith("environments.surveys.summary.email_sent");
expect(toast.success).toHaveBeenCalledWith("environments.surveys.share.send_email.email_sent");
});
test("handles send preview email failure (server error)", async () => {
@@ -163,7 +213,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -176,7 +228,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -190,7 +244,9 @@ describe("EmailTab", () => {
render(<EmailTab surveyId={surveyId} email={userEmail} />);
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const sendPreviewButton = screen.getByRole("button", { name: "send preview email" });
const sendPreviewButton = screen.getByRole("button", {
name: "environments.surveys.share.send_email.send_preview_email",
});
await userEvent.click(sendPreviewButton);
expect(sendEmbedSurveyPreviewEmailAction).toHaveBeenCalledWith({ surveyId });
@@ -208,14 +264,19 @@ describe("EmailTab", () => {
test("renders default email if email prop is not provided", async () => {
render(<EmailTab surveyId={surveyId} email="" />);
await waitFor(() => {
expect(screen.getByText("To : user@mail.com")).toBeInTheDocument();
expect(
screen.getByText((content, element) => {
return (
element?.textContent === "environments.surveys.share.send_email.email_to_label : 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 });
@@ -223,7 +284,7 @@ describe("EmailTab", () => {
await waitFor(() => expect(vi.mocked(getEmailHtmlAction)).toHaveBeenCalled());
const viewEmbedButton = screen.getByRole("button", {
name: "environments.surveys.summary.view_embed_code_for_email",
name: "environments.surveys.share.send_email.embed_code_tab",
});
await userEvent.click(viewEmbedButton);

View File

@@ -0,0 +1,155 @@
"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 { TabBar } from "@/modules/ui/components/tab-bar";
import { useTranslate } from "@tolgee/react";
import DOMPurify from "dompurify";
import { CopyIcon, SendIcon } 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 [activeTab, setActiveTab] = useState("preview");
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]);
const tabs = [
{
id: "preview",
label: t("environments.surveys.share.send_email.email_preview_tab"),
},
{
id: "embed",
label: t("environments.surveys.share.send_email.embed_code_tab"),
},
];
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.share.send_email.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"));
}
};
const renderTabContent = () => {
if (activeTab === "preview") {
return (
<div className="space-y-4 pb-4">
<div className="flex-1 overflow-y-auto rounded-lg 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 className="h-3 w-3 rounded-full bg-amber-500" />
<div className="h-3 w-3 rounded-full bg-emerald-500" />
</div>
<div>
<div className="mb-2 border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_to_label")} : {email || "user@mail.com"}
</div>
<div className="border-b border-slate-200 pb-2 text-sm">
{t("environments.surveys.share.send_email.email_subject_label")} :{" "}
{t("environments.surveys.share.send_email.formbricks_email_survey_preview")}
</div>
<div className="p-2">
{emailHtml ? (
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(emailHtml) }} />
) : (
<LoadingSpinner />
)}
</div>
</div>
</div>
<Button
title={t("environments.surveys.share.send_email.send_preview_email")}
aria-label={t("environments.surveys.share.send_email.send_preview_email")}
onClick={() => sendPreviewEmail()}
className="shrink-0">
{t("environments.surveys.share.send_email.send_preview")}
<SendIcon />
</Button>
</div>
);
}
if (activeTab === "embed") {
return (
<div className="space-y-4 pb-4">
<CodeBlock
customCodeClass="text-sm h-96 overflow-y-scroll"
language="html"
showCopyToClipboard
noMargin>
{emailHtml}
</CodeBlock>
<Button
title={t("environments.surveys.share.send_email.copy_embed_code")}
aria-label={t("environments.surveys.share.send_email.copy_embed_code")}
onClick={() => {
try {
navigator.clipboard.writeText(emailHtml);
toast.success(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard"));
} catch {
toast.error(t("environments.surveys.share.send_email.embed_code_copied_to_clipboard_failed"));
}
}}
className="shrink-0">
{t("common.copy_code")}
<CopyIcon />
</Button>
</div>
);
}
return null;
};
return (
<div className="flex h-full w-full flex-col space-y-4">
<TabBar
tabs={tabs}
activeId={activeTab}
setActiveId={setActiveTab}
tabStyle="button"
className="h-10 min-h-10 rounded-md border border-slate-200 bg-slate-100"
/>
<div className="flex-1">{renderTabContent()}</div>
</div>
);
};

View File

@@ -192,31 +192,26 @@ describe("PersonalLinksTab", () => {
cleanup();
});
test("renders the component with correct title and description", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_title")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.generate_personal_links_description")
).toBeInTheDocument();
});
test("renders recipients section with segment selection", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("common.recipients")).toBeInTheDocument();
expect(screen.getByTestId("select")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.create_and_manage_segments")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.create_and_manage_segments")
).toBeInTheDocument();
});
test("renders expiry date section with date picker", () => {
render(<PersonalLinksTab {...defaultProps} />);
expect(screen.getByText("environments.surveys.summary.expiry_date_optional")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_optional")
).toBeInTheDocument();
expect(screen.getByTestId("date-picker")).toBeInTheDocument();
expect(screen.getByText("environments.surveys.summary.expiry_date_description")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.expiry_date_description")
).toBeInTheDocument();
});
test("renders generate button with correct initial state", () => {
@@ -225,7 +220,9 @@ describe("PersonalLinksTab", () => {
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.getByText("environments.surveys.summary.generate_and_download_links")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.generate_and_download_links")
).toBeInTheDocument();
expect(screen.getByTestId("download-icon")).toBeInTheDocument();
});
@@ -234,7 +231,7 @@ describe("PersonalLinksTab", () => {
expect(screen.getByTestId("alert")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.summary.personal_links_work_with_segments")
screen.getByText("environments.surveys.share.personal_links.work_with_segments")
).toBeInTheDocument();
expect(screen.getByTestId("link")).toHaveAttribute(
"href",
@@ -259,7 +256,9 @@ describe("PersonalLinksTab", () => {
render(<PersonalLinksTab {...propsWithPrivateSegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("select")).toHaveAttribute("data-disabled", "true");
expect(screen.getByTestId("button")).toBeDisabled();
});
@@ -341,10 +340,13 @@ describe("PersonalLinksTab", () => {
});
// Verify loading toast
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("generates links with expiry date when date is selected", async () => {
@@ -439,10 +441,13 @@ describe("PersonalLinksTab", () => {
fireEvent.click(generateButton);
// Verify loading toast is called
expect(mockToast.loading).toHaveBeenCalledWith("environments.surveys.summary.generating_links_toast", {
duration: 5000,
id: "generating-links",
});
expect(mockToast.loading).toHaveBeenCalledWith(
"environments.surveys.share.personal_links.generating_links_toast",
{
duration: 5000,
id: "generating-links",
}
);
});
test("button is disabled when no segment is selected", () => {
@@ -472,7 +477,9 @@ describe("PersonalLinksTab", () => {
render(<PersonalLinksTab {...propsWithEmptySegments} />);
expect(screen.getByText("environments.surveys.summary.no_segments_available")).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.personal_links.no_segments_available")
).toBeInTheDocument();
expect(screen.getByTestId("button")).toBeDisabled();
});

View File

@@ -1,9 +1,17 @@
"use client";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DatePicker } from "@/modules/ui/components/date-picker";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import {
Select,
SelectContent,
@@ -14,8 +22,8 @@ import {
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { TSegment } from "@formbricks/types/segment";
import { generatePersonalLinksAction } from "../../actions";
@@ -28,6 +36,11 @@ interface PersonalLinksTabProps {
isFormbricksCloud: boolean;
}
interface PersonalLinksFormData {
selectedSegment: string;
expiryDate: Date | null;
}
// Custom DatePicker component with date restrictions
const RestrictedDatePicker = ({
date,
@@ -63,8 +76,18 @@ export const PersonalLinksTab = ({
isFormbricksCloud,
}: PersonalLinksTabProps) => {
const { t } = useTranslate();
const [selectedSegment, setSelectedSegment] = useState<string>("");
const [expiryDate, setExpiryDate] = useState<Date | null>(null);
const form = useForm<PersonalLinksFormData>({
defaultValues: {
selectedSegment: "",
expiryDate: null,
},
});
const { watch } = form;
const selectedSegment = watch("selectedSegment");
const expiryDate = watch("expiryDate");
const [isGenerating, setIsGenerating] = useState(false);
const publicSegments = segments.filter((segment) => !segment.isPrivate);
@@ -84,7 +107,7 @@ export const PersonalLinksTab = ({
setIsGenerating(true);
// Show initial toast
toast.loading(t("environments.surveys.summary.generating_links_toast"), {
toast.loading(t("environments.surveys.share.personal_links.generating_links_toast"), {
duration: 5000,
id: "generating-links",
});
@@ -100,7 +123,7 @@ export const PersonalLinksTab = ({
if (result?.data) {
downloadFile(result.data.downloadUrl, result.data.fileName || "personal-links.csv");
toast.success(t("environments.surveys.summary.links_generated_success_toast"), {
toast.success(t("environments.surveys.share.personal_links.links_generated_success_toast"), {
duration: 5000,
id: "generating-links",
});
@@ -117,14 +140,14 @@ export const PersonalLinksTab = ({
// Button state logic
const isButtonDisabled = !selectedSegment || isGenerating || publicSegments.length === 0;
const buttonText = isGenerating
? t("environments.surveys.summary.generating_links")
: t("environments.surveys.summary.generate_and_download_links");
? t("environments.surveys.share.personal_links.generating_links")
: t("environments.surveys.share.personal_links.generate_and_download_links");
if (!isContactsEnabled) {
return (
<UpgradePrompt
title={t("environments.surveys.summary.personal_links_upgrade_prompt_title")}
description={t("environments.surveys.summary.personal_links_upgrade_prompt_description")}
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
buttons={[
{
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
@@ -144,88 +167,82 @@ export const PersonalLinksTab = ({
}
return (
<div className="flex h-full grow flex-col gap-6">
<div>
<h2 className="mb-2 text-lg font-semibold text-slate-800">
{t("environments.surveys.summary.generate_personal_links_title")}
</h2>
<p className="text-sm text-slate-600">
{t("environments.surveys.summary.generate_personal_links_description")}
</p>
</div>
<div className="flex h-full flex-col justify-between space-y-4">
<FormProvider {...form}>
<div className="flex grow flex-col gap-6">
{/* Recipients Section */}
<FormField
control={form.control}
name="selectedSegment"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common.recipients")}</FormLabel>
<FormControl>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={publicSegments.length === 0}>
<SelectTrigger className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.share.personal_links.no_segments_available")
: t("environments.surveys.share.personal_links.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.create_and_manage_segments")}
</FormDescription>
</FormItem>
)}
/>
<div className="space-y-6">
{/* Recipients Section */}
<div>
<label htmlFor="segment-select" className="mb-2 block text-sm font-medium text-slate-700">
{t("common.recipients")}
</label>
<Select
value={selectedSegment}
onValueChange={setSelectedSegment}
disabled={publicSegments.length === 0}>
<SelectTrigger id="segment-select" className="w-full bg-white">
<SelectValue
placeholder={
publicSegments.length === 0
? t("environments.surveys.summary.no_segments_available")
: t("environments.surveys.summary.select_segment")
}
/>
</SelectTrigger>
<SelectContent>
{publicSegments.map((segment) => (
<SelectItem key={segment.id} value={segment.id}>
{segment.title}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.create_and_manage_segments")}
</p>
{/* Expiry Date Section */}
<FormField
control={form.control}
name="expiryDate"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.personal_links.expiry_date_optional")}</FormLabel>
<FormControl>
<RestrictedDatePicker date={field.value} updateSurveyDate={field.onChange} />
</FormControl>
<FormDescription>
{t("environments.surveys.share.personal_links.expiry_date_description")}
</FormDescription>
</FormItem>
)}
/>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
{/* Expiry Date Section */}
<div>
<label htmlFor="expiry-date-picker" className="mb-2 block text-sm font-medium text-slate-700">
{t("environments.surveys.summary.expiry_date_optional")}
</label>
<div id="expiry-date-picker">
<RestrictedDatePicker
date={expiryDate}
updateSurveyDate={(date: Date | null) => setExpiryDate(date)}
/>
</div>
<p className="mt-1 text-xs text-slate-500">
{t("environments.surveys.summary.expiry_date_description")}
</p>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerateLinks}
disabled={isButtonDisabled}
loading={isGenerating}
className="w-fit">
<DownloadIcon className="mr-2 h-4 w-4" />
{buttonText}
</Button>
</div>
<hr />
{/* Info Box */}
<Alert variant="info" size="small">
<AlertTitle>{t("environments.surveys.summary.personal_links_work_with_segments")}</AlertTitle>
<AlertButton>
<Link
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration"
target="_blank"
rel="noopener noreferrer">
{t("common.learn_more")}
</Link>
</AlertButton>
</Alert>
</FormProvider>
<DocumentationLinks
links={[
{
title: t("environments.surveys.share.personal_links.work_with_segments"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/advanced-targeting#segment-configuration",
},
]}
/>
</div>
);
};

View File

@@ -0,0 +1,284 @@
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 { QRCodeTab } from "./qr-code-tab";
// Mock the QR code options utility
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options",
() => ({
getQRCodeOptions: vi.fn((width: number, height: number) => ({
width,
height,
type: "svg",
data: "",
margin: 0,
qrOptions: {
typeNumber: 0,
mode: "Byte",
errorCorrectionLevel: "L",
},
imageOptions: {
saveAsBlob: true,
hideBackgroundDots: false,
imageSize: 0,
margin: 0,
},
dotsOptions: {
type: "extra-rounded",
color: "#000000",
roundSize: true,
},
backgroundOptions: {
color: "#ffffff",
},
cornersSquareOptions: {
type: "dot",
color: "#000000",
},
cornersDotOptions: {
type: "dot",
color: "#000000",
},
})),
})
);
// Mock UI components
vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children, variant }: { children: React.ReactNode; variant?: string }) => (
<div data-testid="alert" data-variant={variant}>
{children}
</div>
),
AlertDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-description">{children}</div>
),
AlertTitle: ({ children }: { children: React.ReactNode }) => (
<div data-testid="alert-title">{children}</div>
),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({
children,
onClick,
disabled,
variant,
size,
className,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
variant?: string;
size?: string;
className?: string;
}) => (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={className}
data-variant={variant}
data-size={size}
data-testid="button">
{children}
</button>
),
}));
// Mock lucide-react icons
vi.mock("lucide-react", () => ({
Download: () => <div data-testid="download-icon">Download</div>,
LoaderCircle: ({ className }: { className?: string }) => (
<div className={className} data-testid="loader-circle">
LoaderCircle
</div>
),
RefreshCw: ({ className }: { className?: string }) => (
<div className={className} data-testid="refresh-icon">
RefreshCw
</div>
),
}));
// Mock logger
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
// Mock QRCodeStyling
const mockQRCodeStyling = {
update: vi.fn(),
append: vi.fn(),
download: vi.fn(),
};
// Simple boolean flag to control mock behavior
let shouldMockThrowError = false;
// @ts-ignore - Ignore TypeScript error for mock
vi.mock("qr-code-styling", () => ({
default: vi.fn(() => {
// Default to success, only throw error when explicitly requested
if (shouldMockThrowError) {
throw new Error("QR code generation failed");
}
return mockQRCodeStyling;
}),
}));
const mockSurveyUrl = "https://example.com/survey/123";
describe("QRCodeTab", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
// Reset to success state by default
shouldMockThrowError = false;
// Reset mock implementations
mockQRCodeStyling.update.mockReset();
mockQRCodeStyling.append.mockReset();
mockQRCodeStyling.download.mockReset();
// Set up default mock behavior
mockQRCodeStyling.update.mockImplementation(() => {});
mockQRCodeStyling.append.mockImplementation(() => {});
mockQRCodeStyling.download.mockImplementation(() => {});
});
afterEach(() => {
cleanup();
});
describe("QR Code generation", () => {
test("attempts to generate QR code when surveyUrl is provided", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Wait for either success or error state
await waitFor(() => {
const hasButton = screen.queryByTestId("button");
const hasAlert = screen.queryByTestId("alert");
expect(hasButton || hasAlert).toBeTruthy();
});
});
test("shows download button when QR code generation succeeds", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});
});
describe("Error handling", () => {
test("shows error state when QR code generation fails", async () => {
shouldMockThrowError = true;
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("alert")).toBeInTheDocument();
});
expect(screen.getByTestId("alert-title")).toHaveTextContent("common.something_went_wrong");
expect(screen.getByTestId("alert-description")).toHaveTextContent(
"environments.surveys.summary.qr_code_generation_failed"
);
});
});
describe("Download functionality", () => {
test("has clickable download button when QR code is available", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const downloadButton = screen.getByTestId("button");
expect(downloadButton).toBeInTheDocument();
expect(downloadButton).toHaveAttribute("type", "button");
// Button should be clickable
await userEvent.click(downloadButton);
// If the button is clicked without throwing, it's working
});
test("handles button interactions properly", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
// Test that button can be interacted with
await userEvent.click(button);
// Button should still be present after click
expect(screen.getByTestId("button")).toBeInTheDocument();
});
test("shows appropriate state when surveyUrl is empty", async () => {
render(<QRCodeTab surveyUrl="" />);
// Should show button (but disabled) when URL is empty, no alert
const button = screen.getByTestId("button");
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
expect(screen.queryByTestId("alert")).not.toBeInTheDocument();
});
});
describe("Component lifecycle", () => {
test("responds to surveyUrl changes", async () => {
const { rerender } = render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Initial render should show download button
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
const newSurveyUrl = "https://example.com/survey/456";
rerender(<QRCodeTab surveyUrl={newSurveyUrl} />);
// After rerender, button should still be present
await waitFor(() => {
expect(screen.getByTestId("button")).toBeInTheDocument();
});
});
});
describe("Accessibility", () => {
test("has proper button labels and states", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
await waitFor(() => {
const downloadButton = screen.getByTestId("button");
expect(downloadButton).toBeInTheDocument();
expect(downloadButton).toHaveAttribute("type", "button");
});
});
test("shows appropriate loading or success state", async () => {
render(<QRCodeTab surveyUrl={mockSurveyUrl} />);
// Component should show either loading or success content
await waitFor(() => {
const hasButton = screen.queryByTestId("button");
const hasLoader = screen.queryByTestId("loader-circle");
expect(hasButton || hasLoader).toBeTruthy();
});
});
});
});

View File

@@ -0,0 +1,115 @@
"use client";
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Download, LoaderCircle } from "lucide-react";
import QRCodeStyling from "qr-code-styling";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { logger } from "@formbricks/logger";
interface QRCodeTabProps {
surveyUrl: string;
}
export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
const { t } = useTranslate();
const qrCodeRef = useRef<HTMLDivElement>(null);
const qrInstance = useRef<QRCodeStyling | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
useEffect(() => {
const generateQRCode = async () => {
try {
setIsLoading(true);
setHasError(false);
qrInstance.current ??= new QRCodeStyling(getQRCodeOptions(184, 184));
if (surveyUrl && qrInstance.current) {
qrInstance.current.update({ data: surveyUrl });
if (qrCodeRef.current) {
qrCodeRef.current.innerHTML = "";
qrInstance.current.append(qrCodeRef.current);
}
}
} catch (error) {
logger.error("Failed to generate QR code:", error);
setHasError(true);
} finally {
setIsLoading(false);
}
};
if (surveyUrl) {
generateQRCode();
}
return () => {
const instance = qrInstance.current;
if (instance) {
qrInstance.current = null;
}
};
}, [surveyUrl]);
const downloadQRCode = async () => {
try {
setIsDownloading(true);
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
downloadInstance.update({ data: surveyUrl });
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
} catch (error) {
logger.error("Failed to download QR code:", error);
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
} finally {
setIsDownloading(false);
}
};
return (
<>
{isLoading && (
<div className="flex flex-col items-center gap-2">
<LoaderCircle className="h-8 w-8 animate-spin text-slate-500" />
<p className="text-sm text-slate-500">{t("environments.surveys.summary.generating_qr_code")}</p>
</div>
)}
{hasError && (
<Alert variant="error">
<AlertTitle>{t("common.something_went_wrong")}</AlertTitle>
<AlertDescription>{t("environments.surveys.summary.qr_code_generation_failed")}</AlertDescription>
</Alert>
)}
{!isLoading && !hasError && (
<div className="flex flex-col items-start justify-center gap-4">
<div className="flex h-[184px] w-[184px] items-center justify-center overflow-hidden rounded-lg border bg-white">
<div ref={qrCodeRef} className="h-full w-full" />
</div>
<Button
onClick={downloadQRCode}
data-testid="download-qr-code-button"
disabled={!surveyUrl || isDownloading || hasError}
className="flex items-center gap-2">
{isDownloading
? t("environments.surveys.summary.downloading_qr_code")
: t("environments.surveys.summary.download_qr_code")}
{isDownloading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,551 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ShareViewType } from "../../types/share";
import { ShareView } from "./share-view";
// Mock sidebar components
vi.mock("@/modules/ui/components/sidebar", () => ({
SidebarProvider: ({ children, open, className, style }: any) => (
<div data-testid="sidebar-provider" data-open={open} className={className} style={style}>
{children}
</div>
),
Sidebar: ({ children }: { children: React.ReactNode }) => <div data-testid="sidebar">{children}</div>,
SidebarContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-content">{children}</div>
),
SidebarGroup: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group">{children}</div>
),
SidebarGroupContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group-content">{children}</div>
),
SidebarGroupLabel: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-group-label">{children}</div>
),
SidebarMenu: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-menu">{children}</div>
),
SidebarMenuItem: ({ children }: { children: React.ReactNode }) => (
<div data-testid="sidebar-menu-item">{children}</div>
),
SidebarMenuButton: ({
children,
onClick,
tooltip,
className,
isActive,
}: {
children: React.ReactNode;
onClick: () => void;
tooltip: string;
className?: string;
isActive?: boolean;
}) => (
<button type="button" onClick={onClick} className={className} aria-label={tooltip} data-active={isActive}>
{children}
</button>
),
}));
// Mock child components
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("./anonymous-links-tab", () => ({
AnonymousLinksTab: (props: {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
locale: TUserLocale;
}) => (
<div data-testid="anonymous-links-tab">
AnonymousLinksTab Content for {props.survey.id} at {props.surveyUrl}
</div>
),
}));
vi.mock("./qr-code-tab", () => ({
QRCodeTab: (props: { surveyUrl: string }) => (
<div data-testid="qr-code-tab">QRCodeTab Content for {props.surveyUrl}</div>
),
}));
vi.mock("./website-embed-tab", () => ({
WebsiteEmbedTab: (props: { surveyUrl: string }) => (
<div data-testid="website-embed-tab">WebsiteEmbedTab Content for {props.surveyUrl}</div>
),
}));
vi.mock("./dynamic-popup-tab", () => ({
DynamicPopupTab: (props: { environmentId: string; surveyId: string }) => (
<div data-testid="dynamic-popup-tab">
DynamicPopupTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("./tab-container", () => ({
TabContainer: (props: { children: React.ReactNode; title: string; description: string }) => (
<div data-testid="tab-container">
<div data-testid="tab-title">{props.title}</div>
<div data-testid="tab-description">{props.description}</div>
{props.children}
</div>
),
}));
vi.mock("./personal-links-tab", () => ({
PersonalLinksTab: (props: { surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {props.surveyId} in {props.environmentId}
</div>
),
}));
vi.mock("./social-media-tab", () => ({
SocialMediaTab: (props: { surveyUrl: string; surveyTitle: string }) => (
<div data-testid="social-media-tab">
SocialMediaTab Content for {props.surveyTitle} at {props.surveyUrl}
</div>
),
}));
vi.mock("@/modules/ui/components/upgrade-prompt", () => ({
UpgradePrompt: (props: { title: string; description: string }) => (
<div data-testid="upgrade-prompt">
{props.title} - {props.description}
</div>
),
}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
CopyIcon: () => <div data-testid="copy-icon">CopyIcon</div>,
ArrowLeftIcon: () => <div data-testid="arrow-left-icon">ArrowLeftIcon</div>,
ArrowUpRightIcon: () => <div data-testid="arrow-up-right-icon">ArrowUpRightIcon</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>,
CheckCircle2Icon: () => <div data-testid="check-circle-2-icon">CheckCircle2Icon</div>,
AlertCircleIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-circle-icon">
AlertCircleIcon
</div>
),
AlertTriangleIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="alert-triangle-icon">
AlertTriangleIcon
</div>
),
InfoIcon: ({ className }: { className?: string }) => (
<div className={className} data-testid="info-icon">
InfoIcon
</div>
),
Download: ({ className }: { className?: string }) => (
<div className={className} data-testid="download-icon">
Download
</div>
),
Code2Icon: () => <div data-testid="code2-icon">Code2Icon</div>,
QrCodeIcon: () => <div data-testid="qr-code-icon">QrCodeIcon</div>,
Share2Icon: () => <div data-testid="share2-icon">Share2Icon</div>,
SquareStack: () => <div data-testid="square-stack-icon">SquareStack</div>,
UserIcon: () => <div data-testid="user-icon">UserIcon</div>,
}));
// Mock tooltip and typography components
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@/modules/ui/components/typography", () => ({
Small: ({ children }: { children: React.ReactNode }) => <small>{children}</small>,
}));
// Mock button component
vi.mock("@/modules/ui/components/button", () => ({
Button: ({
children,
onClick,
className,
variant,
}: {
children: React.ReactNode;
onClick: () => void;
className?: string;
variant?: string;
}) => (
<button type="button" onClick={onClick} className={className} data-variant={variant}>
{children}
</button>
),
}));
// Mock cn utility
vi.mock("@/lib/cn", () => ({
cn: (...args: any[]) => args.filter(Boolean).join(" "),
}));
// Mock i18n
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
// Mock component imports for tabs
const MockEmailTab = ({ surveyId, email }: { surveyId: string; email: string }) => (
<div data-testid="email-tab">
EmailTab Content for {surveyId} with {email}
</div>
);
const MockAnonymousLinksTab = ({ survey, surveyUrl }: { survey: any; surveyUrl: string }) => (
<div data-testid="anonymous-links-tab">
AnonymousLinksTab Content for {survey.id} at {surveyUrl}
</div>
);
const MockWebsiteEmbedTab = ({ surveyUrl }: { surveyUrl: string }) => (
<div data-testid="website-embed-tab">WebsiteEmbedTab Content for {surveyUrl}</div>
);
const MockDynamicPopupTab = ({ environmentId, surveyId }: { environmentId: string; surveyId: string }) => (
<div data-testid="dynamic-popup-tab">
DynamicPopupTab Content for {surveyId} in {environmentId}
</div>
);
const MockQRCodeTab = ({ surveyUrl }: { surveyUrl: string }) => (
<div data-testid="qr-code-tab">QRCodeTab Content for {surveyUrl}</div>
);
const MockPersonalLinksTab = ({ surveyId, environmentId }: { surveyId: string; environmentId: string }) => (
<div data-testid="personal-links-tab">
PersonalLinksTab Content for {surveyId} in {environmentId}
</div>
);
const MockSocialMediaTab = ({ surveyUrl, surveyTitle }: { surveyUrl: string; surveyTitle: string }) => (
<div data-testid="social-media-tab">
SocialMediaTab Content for {surveyTitle} at {surveyUrl}
</div>
);
const mockSurvey = {
id: "survey1",
type: "link",
name: "Test Survey",
status: "inProgress",
environmentId: "env1",
createdAt: new Date(),
updatedAt: new Date(),
questions: [],
displayOption: "displayOnce",
recontactDays: 0,
triggers: [],
languages: [],
autoClose: null,
delay: 0,
autoComplete: null,
runOnDate: null,
closeOnDate: null,
singleUse: { enabled: false, isEncrypted: false },
styling: null,
} as any;
const mockTabs = [
{
id: ShareViewType.EMAIL,
label: "Email",
icon: () => <div data-testid="email-tab-icon" />,
componentType: MockEmailTab,
componentProps: { surveyId: "survey1", email: "test@example.com" },
title: "Send Email",
description: "Send survey via email",
},
{
id: ShareViewType.WEBSITE_EMBED,
label: "Website Embed",
icon: () => <div data-testid="website-embed-tab-icon" />,
componentType: MockWebsiteEmbedTab,
componentProps: { surveyUrl: "http://example.com/survey1" },
title: "Embed on Website",
description: "Embed survey on your website",
},
{
id: ShareViewType.DYNAMIC_POPUP,
label: "Dynamic Popup",
icon: () => <div data-testid="dynamic-popup-tab-icon" />,
componentType: MockDynamicPopupTab,
componentProps: { environmentId: "env1", surveyId: "survey1" },
title: "Dynamic Popup",
description: "Show survey as popup",
},
{
id: ShareViewType.ANON_LINKS,
label: "Anonymous Links",
icon: () => <div data-testid="anonymous-links-tab-icon" />,
componentType: MockAnonymousLinksTab,
componentProps: {
survey: mockSurvey,
surveyUrl: "http://example.com/survey1",
publicDomain: "http://example.com",
setSurveyUrl: vi.fn(),
locale: "en" as any,
},
title: "Anonymous Links",
description: "Share anonymous links",
},
{
id: ShareViewType.QR_CODE,
label: "QR Code",
icon: () => <div data-testid="qr-code-tab-icon" />,
componentType: MockQRCodeTab,
componentProps: { surveyUrl: "http://example.com/survey1" },
title: "QR Code",
description: "Generate QR code",
},
{
id: ShareViewType.PERSONAL_LINKS,
label: "Personal Links",
icon: () => <div data-testid="personal-links-tab-icon" />,
componentType: MockPersonalLinksTab,
componentProps: { surveyId: "survey1", environmentId: "env1" },
title: "Personal Links",
description: "Create personal links",
},
{
id: ShareViewType.SOCIAL_MEDIA,
label: "Social Media",
icon: () => <div data-testid="social-media-tab-icon" />,
componentType: MockSocialMediaTab,
componentProps: { surveyUrl: "http://example.com/survey1", surveyTitle: "Test Survey" },
title: "Social Media",
description: "Share on social media",
},
];
const defaultProps = {
tabs: mockTabs,
activeId: ShareViewType.EMAIL,
setActiveId: vi.fn(),
};
// Mock window object for resize testing
Object.defineProperty(window, "innerWidth", {
writable: true,
configurable: true,
value: 1024,
});
describe("ShareView", () => {
beforeEach(() => {
// Reset window size to default before each test
window.innerWidth = 1024;
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders sidebar with tabs", () => {
render(<ShareView {...defaultProps} />);
// Sidebar should always be rendered
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
expect(sidebarLabel).toBeInTheDocument();
});
test("renders desktop tabs", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Desktop sidebar should be rendered
const sidebarLabel = screen.getByText("environments.surveys.share.share_view_title");
expect(sidebarLabel).toBeInTheDocument();
});
test("calls setActiveId when a tab is clicked (desktop)", async () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
await userEvent.click(websiteEmbedTabButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
});
test("renders EmailTab when activeId is EMAIL", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
expect(screen.getByTestId("email-tab")).toBeInTheDocument();
expect(screen.getByText("EmailTab Content for survey1 with test@example.com")).toBeInTheDocument();
});
test("renders WebsiteEmbedTab when activeId is WEBSITE_EMBED", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.WEBSITE_EMBED} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("website-embed-tab")).toBeInTheDocument();
expect(screen.getByText("WebsiteEmbedTab Content for http://example.com/survey1")).toBeInTheDocument();
});
test("renders DynamicPopupTab when activeId is DYNAMIC_POPUP", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.DYNAMIC_POPUP} />);
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
expect(screen.getByTestId("dynamic-popup-tab")).toBeInTheDocument();
expect(screen.getByText("DynamicPopupTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders AnonymousLinksTab when activeId is ANON_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.ANON_LINKS} />);
expect(screen.getByTestId("anonymous-links-tab")).toBeInTheDocument();
expect(
screen.getByText("AnonymousLinksTab Content for survey1 at http://example.com/survey1")
).toBeInTheDocument();
});
test("renders QRCodeTab when activeId is QR_CODE", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.QR_CODE} />);
expect(screen.getByTestId("qr-code-tab")).toBeInTheDocument();
});
test("renders nothing when activeId doesn't match any tab", () => {
// Create a special case with no matching tab
const propsWithNoMatchingTab = {
...defaultProps,
tabs: mockTabs.slice(0, 3), // Only include first 3 tabs
activeId: ShareViewType.SOCIAL_MEDIA, // Use a tab not in the subset
};
render(<ShareView {...propsWithNoMatchingTab} />);
// Should not render any tab content for non-matching activeId
expect(screen.queryByTestId("email-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("website-embed-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("dynamic-popup-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("anonymous-links-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("qr-code-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("personal-links-tab")).not.toBeInTheDocument();
expect(screen.queryByTestId("social-media-tab")).not.toBeInTheDocument();
});
test("renders PersonalLinksTab when activeId is PERSONAL_LINKS", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.PERSONAL_LINKS} />);
expect(screen.getByTestId("personal-links-tab")).toBeInTheDocument();
expect(screen.getByText("PersonalLinksTab Content for survey1 in env1")).toBeInTheDocument();
});
test("renders SocialMediaTab when activeId is SOCIAL_MEDIA", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.SOCIAL_MEDIA} />);
expect(screen.getByTestId("social-media-tab")).toBeInTheDocument();
expect(
screen.getByText("SocialMediaTab Content for Test Survey at http://example.com/survey1")
).toBeInTheDocument();
});
test("calls setActiveId when a responsive tab is clicked", async () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components containing icons
const responsiveButtons = screen.getAllByTestId("website-embed-tab-icon");
// The responsive button should be the one inside the md:hidden container
const responsiveButton = responsiveButtons
.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
})
?.closest("button");
if (responsiveButton) {
await userEvent.click(responsiveButton);
expect(defaultProps.setActiveId).toHaveBeenCalledWith(ShareViewType.WEBSITE_EMBED);
}
});
test("applies active styles to the active tab (desktop)", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
const emailTabButton = screen.getByLabelText("Email");
expect(emailTabButton).toHaveClass("bg-slate-100");
expect(emailTabButton).toHaveClass("font-medium");
expect(emailTabButton).toHaveClass("text-slate-900");
const websiteEmbedTabButton = screen.getByLabelText("Website Embed");
expect(websiteEmbedTabButton).not.toHaveClass("bg-slate-100");
expect(websiteEmbedTabButton).not.toHaveClass("font-medium");
});
test("applies active styles to the active tab (responsive)", () => {
render(<ShareView {...defaultProps} activeId={ShareViewType.EMAIL} />);
// Get responsive buttons - these are Button components with ghost variant
const responsiveButtons = screen.getAllByTestId("email-tab-icon");
const responsiveEmailButton = responsiveButtons
.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
})
?.closest("button");
if (responsiveEmailButton) {
// Check that the button has the active classes
expect(responsiveEmailButton).toHaveClass("bg-white text-slate-900 shadow-sm hover:bg-white");
}
const responsiveWebsiteEmbedButtons = screen.getAllByTestId("website-embed-tab-icon");
const responsiveWebsiteEmbedButton = responsiveWebsiteEmbedButtons
.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
})
?.closest("button");
if (responsiveWebsiteEmbedButton) {
expect(responsiveWebsiteEmbedButton).toHaveClass(
"border-transparent text-slate-700 hover:text-slate-900"
);
}
});
test("renders all tabs from props", () => {
render(<ShareView {...defaultProps} />);
// Check that all tabs are rendered in the sidebar
mockTabs.forEach((tab) => {
expect(screen.getByLabelText(tab.label)).toBeInTheDocument();
});
});
test("renders responsive buttons for all tabs", () => {
render(<ShareView {...defaultProps} />);
// Check that responsive buttons are rendered for all tabs
const expectedTestIds = [
"email-tab-icon",
"website-embed-tab-icon",
"dynamic-popup-tab-icon",
"anonymous-links-tab-icon",
"qr-code-tab-icon",
"personal-links-tab-icon",
"social-media-tab-icon",
];
expectedTestIds.forEach((testId) => {
const responsiveButtons = screen.getAllByTestId(testId);
const responsiveButton = responsiveButtons.find((icon) => {
const button = icon.closest("button");
return button && button.getAttribute("data-variant") === "ghost";
});
expect(responsiveButton).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,132 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/modules/ui/components/sidebar";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { Small } from "@/modules/ui/components/typography";
import { useTranslate } from "@tolgee/react";
import { useEffect, useState } from "react";
interface ShareViewProps {
tabs: Array<{
id: ShareViewType;
label: string;
icon: React.ElementType;
componentType: React.ComponentType<any>;
componentProps: any;
title: string;
description?: string;
}>;
activeId: ShareViewType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViewType>>;
}
export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
const { t } = useTranslate();
const [isLargeScreen, setIsLargeScreen] = useState(true);
useEffect(() => {
const checkScreenSize = () => {
setIsLargeScreen(window.innerWidth >= 1024);
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
const renderActiveTab = () => {
const activeTab = tabs.find((tab) => tab.id === activeId);
if (!activeTab) return null;
const { componentType: Component, componentProps } = activeTab;
return (
<TabContainer key={activeTab.id} title={activeTab.title} description={activeTab.description ?? ""}>
<Component {...componentProps} />
</TabContainer>
);
};
return (
<div className="h-full">
<div className={`flex h-full lg:grid lg:grid-cols-4`}>
<SidebarProvider
open={isLargeScreen}
className="flex min-h-0 w-auto lg:col-span-1"
style={
{
"--sidebar-width": "100%",
} as React.CSSProperties
}>
<Sidebar className="relative h-full p-0" variant="inset" collapsible="icon">
<SidebarContent className="h-full rounded-l-lg border-r border-slate-200 bg-white p-4">
<SidebarGroup className="p-0">
<SidebarGroupLabel>
<Small className="text-xs text-slate-500">
{t("environments.surveys.share.share_view_title")}
</Small>
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu className="flex flex-col gap-1">
{tabs.map((tab) => (
<SidebarMenuItem key={tab.id}>
<SidebarMenuButton
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId ? "bg-slate-100 font-medium text-slate-900" : "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
</SidebarProvider>
<div
className={`h-full w-full grow overflow-y-auto rounded-lg bg-slate-50 px-4 py-6 md:rounded-l-lg lg:col-span-3 lg:p-6`}>
{renderActiveTab()}
<div className="flex justify-center gap-2 rounded-md pt-6 text-center md:hidden">
{tabs.map((tab) => (
<TooltipRenderer tooltipContent={tab.label} key={tab.id}>
<Button
variant="ghost"
onClick={() => setActiveId(tab.id)}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>
<tab.icon className="h-4 w-4 text-slate-700" />
</Button>
</TooltipRenderer>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,138 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { SocialMediaTab } from "./social-media-tab";
// Mock next/link
vi.mock("next/link", () => ({
default: ({ href, children, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
),
}));
// Mock window.open
Object.defineProperty(window, "open", {
writable: true,
value: vi.fn(),
});
const mockSurveyUrl = "https://app.formbricks.com/s/survey1";
const mockSurveyTitle = "Test Survey";
const expectedPlatforms = [
{ name: "LinkedIn", description: "Share on LinkedIn" },
{ name: "Threads", description: "Share on Threads" },
{ name: "Facebook", description: "Share on Facebook" },
{ name: "Reddit", description: "Share on Reddit" },
{ name: "X", description: "Share on X (formerly Twitter)" },
];
describe("SocialMediaTab", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders all social media platforms with correct names", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
expectedPlatforms.forEach((platform) => {
expect(screen.getByText(platform.name)).toBeInTheDocument();
});
});
test("renders source tracking alert with correct content", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
expect(
screen.getByText("environments.surveys.share.social_media.source_tracking_enabled")
).toBeInTheDocument();
expect(
screen.getByText("environments.surveys.share.social_media.source_tracking_enabled_alert_description")
).toBeInTheDocument();
expect(screen.getByText("common.learn_more")).toBeInTheDocument();
const learnMoreButton = screen.getByRole("button", { name: "common.learn_more" });
expect(learnMoreButton).toBeInTheDocument();
});
test("renders platform buttons for all platforms", () => {
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const platformButtons = expectedPlatforms.map((platform) =>
screen.getByRole("button", { name: new RegExp(platform.name, "i") })
);
expect(platformButtons).toHaveLength(expectedPlatforms.length);
});
test("opens sharing window when LinkedIn button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("linkedin.com/shareArticle"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("includes source tracking in shared URLs", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
const calledUrl = mockWindowOpen.mock.calls[0][0] as string;
const decodedUrl = decodeURIComponent(calledUrl);
expect(decodedUrl).toContain("source=linkedin");
});
test("opens sharing window when Facebook button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const facebookButton = screen.getByRole("button", { name: /facebook/i });
await userEvent.click(facebookButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("facebook.com/sharer"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("opens sharing window when X button is clicked", async () => {
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={mockSurveyUrl} surveyTitle={mockSurveyTitle} />);
const xButton = screen.getByRole("button", { name: /^x$/i });
await userEvent.click(xButton);
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining("twitter.com/intent/tweet"),
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
});
test("encodes URLs and titles correctly for sharing", async () => {
const specialCharUrl = "https://app.formbricks.com/s/survey1?param=test&other=value";
const specialCharTitle = "Test Survey & More";
const mockWindowOpen = vi.spyOn(window, "open");
render(<SocialMediaTab surveyUrl={specialCharUrl} surveyTitle={specialCharTitle} />);
const linkedInButton = screen.getByRole("button", { name: /linkedin/i });
await userEvent.click(linkedInButton);
const calledUrl = mockWindowOpen.mock.calls[0][0] as string;
expect(calledUrl).toContain(encodeURIComponent(specialCharTitle));
});
});

View File

@@ -0,0 +1,114 @@
"use client";
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { FacebookIcon } from "@/modules/ui/components/icons/facebook-icon";
import { LinkedinIcon } from "@/modules/ui/components/icons/linkedin-icon";
import { RedditIcon } from "@/modules/ui/components/icons/reddit-icon";
import { ThreadsIcon } from "@/modules/ui/components/icons/threads-icon";
import { XIcon } from "@/modules/ui/components/icons/x-icon";
import { useTranslate } from "@tolgee/react";
import { AlertCircleIcon } from "lucide-react";
import { useMemo } from "react";
interface SocialMediaTabProps {
surveyUrl: string;
surveyTitle: string;
}
export const SocialMediaTab: React.FC<SocialMediaTabProps> = ({ surveyUrl, surveyTitle }) => {
const { t } = useTranslate();
const socialMediaPlatforms = useMemo(() => {
const shareText = surveyTitle;
// Add source tracking to the survey URL
const getTrackedUrl = (platform: string) => {
const sourceParam = `source=${platform.toLowerCase()}`;
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}${sourceParam}`;
};
return [
{
id: "linkedin",
name: "LinkedIn",
icon: <LinkedinIcon />,
url: `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(getTrackedUrl("linkedin"))}&title=${encodeURIComponent(shareText)}`,
description: "Share on LinkedIn",
},
{
id: "threads",
name: "Threads",
icon: <ThreadsIcon />,
url: `https://www.threads.net/intent/post?text=${encodeURIComponent(shareText)}%20${encodeURIComponent(getTrackedUrl("threads"))}`,
description: "Share on Threads",
},
{
id: "facebook",
name: "Facebook",
icon: <FacebookIcon />,
url: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(getTrackedUrl("facebook"))}`,
description: "Share on Facebook",
},
{
id: "reddit",
name: "Reddit",
icon: <RedditIcon />,
url: `https://www.reddit.com/submit?url=${encodeURIComponent(getTrackedUrl("reddit"))}&title=${encodeURIComponent(shareText)}`,
description: "Share on Reddit",
},
{
id: "x",
name: "X",
icon: <XIcon />,
url: `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(getTrackedUrl("x"))}`,
description: "Share on X (formerly Twitter)",
},
];
}, [surveyUrl, surveyTitle]);
const handleSocialShare = (url: string) => {
// Open sharing window
window.open(
url,
"share-dialog",
"width=1024,height=768,location=no,toolbar=no,status=no,menubar=no,scrollbars=yes,resizable=yes,noopener=yes,noreferrer=yes"
);
};
return (
<>
<div className="grid grid-cols-1 gap-4">
{socialMediaPlatforms.map((platform) => (
<Button
key={platform.name}
variant="outline"
className="w-fit bg-white"
onClick={() => handleSocialShare(platform.url)}>
{platform.name}
{platform.icon}
</Button>
))}
</div>
<Alert>
<AlertCircleIcon />
<AlertTitle>{t("environments.surveys.share.social_media.source_tracking_enabled")}</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.social_media.source_tracking_enabled_alert_description")}
</AlertDescription>
<AlertButton
onClick={() => {
window.open(
"https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/source-tracking",
"_blank",
"noopener,noreferrer"
);
}}>
{t("common.learn_more")}
</AlertButton>
</Alert>
</>
);
};

View File

@@ -0,0 +1,83 @@
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { Badge } from "@/modules/ui/components/badge";
import { useTranslate } from "@tolgee/react";
import { BellRing, BlocksIcon, Share2Icon, UserIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
interface SuccessViewProps {
survey: TSurvey;
surveyUrl: string;
publicDomain: string;
setSurveyUrl: (url: string) => void;
user: TUser;
tabs: { id: string; label: string; icon: React.ElementType }[];
handleViewChange: (view: string) => void;
handleEmbedViewWithTab: (tabId: string) => void;
}
export const SuccessView: React.FC<SuccessViewProps> = ({
survey,
surveyUrl,
publicDomain,
setSurveyUrl,
user,
tabs,
handleViewChange,
handleEmbedViewWithTab,
}) => {
const { t } = useTranslate();
const environmentId = survey.environmentId;
return (
<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 gap-8 py-[100px] text-center">
<p className="text-xl font-semibold text-slate-900">
{t("environments.surveys.summary.your_survey_is_public")} 🎉
</p>
<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-8 rounded-b-lg bg-slate-50 px-8 py-4">
<p className="text-sm font-medium text-slate-900">{t("environments.surveys.summary.whats_next")}</p>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={() => handleViewChange("share")}
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<Share2Icon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.share_survey")}
</button>
<button
type="button"
onClick={() => handleEmbedViewWithTab(tabs[1].id)}
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</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-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BellRing className="h-8 w-8 stroke-1 text-slate-900" />
{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-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.setup_integrations")}
</Link>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TabContainer } from "./tab-container";
// Mock components
vi.mock("@/modules/ui/components/typography", () => ({
H3: (props: { children: React.ReactNode }) => <h3 data-testid="h3">{props.children}</h3>,
Small: (props: { color?: string; margin?: string; children: React.ReactNode }) => (
<p data-testid="small" data-color={props.color} data-margin={props.margin}>
{props.children}
</p>
),
}));
describe("TabContainer", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
title: "Test Tab Title",
description: "Test tab description",
children: <div data-testid="tab-content">Tab content</div>,
};
test("renders title with correct props", () => {
render(<TabContainer {...defaultProps} />);
const title = screen.getByTestId("h3");
expect(title).toBeInTheDocument();
expect(title).toHaveTextContent("Test Tab Title");
});
test("renders description with correct text and props", () => {
render(<TabContainer {...defaultProps} />);
const description = screen.getByTestId("small");
expect(description).toBeInTheDocument();
expect(description).toHaveTextContent("Test tab description");
expect(description).toHaveAttribute("data-color", "muted");
expect(description).toHaveAttribute("data-margin", "headerDescription");
});
test("renders children content", () => {
render(<TabContainer {...defaultProps} />);
const tabContent = screen.getByTestId("tab-content");
expect(tabContent).toBeInTheDocument();
expect(tabContent).toHaveTextContent("Tab content");
});
test("renders header with correct structure", () => {
render(<TabContainer {...defaultProps} />);
const header = screen.getByTestId("h3").parentElement;
expect(header).toBeInTheDocument();
expect(header).toContainElement(screen.getByTestId("h3"));
expect(header).toContainElement(screen.getByTestId("small"));
});
test("renders children directly in container", () => {
render(<TabContainer {...defaultProps} />);
const container = screen.getByTestId("h3").parentElement?.parentElement;
expect(container).toContainElement(screen.getByTestId("tab-content"));
});
});

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