mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 14:40:44 -06:00
Compare commits
51 Commits
fix-icon-f
...
fix-verify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfd1bae70 | ||
|
|
09404539d3 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 | ||
|
|
f043314537 | ||
|
|
2ce842dd8d | ||
|
|
43b43839c5 | ||
|
|
8b6e3fec37 | ||
|
|
31bcf98779 | ||
|
|
b35cabcbcc | ||
|
|
4f435f1a1f | ||
|
|
99c1e434df | ||
|
|
b13699801b | ||
|
|
ceb2e85d96 | ||
|
|
c5f8b5ec32 | ||
|
|
bdbd57c2fc | ||
|
|
d44aa17814 | ||
|
|
23d38b4c5b | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 |
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
177
.cursor/rules/storybook-create-new-story.mdc
Normal 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.
|
||||
@@ -189,15 +189,11 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -219,7 +215,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
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
type: bug
|
||||
projects: "formbricks/8"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
@@ -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/'
|
||||
|
||||
|
||||
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
17
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -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"
|
||||
|
||||
7
.github/workflows/e2e.yml
vendored
7
.github/workflows/e2e.yml
vendored
@@ -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-)
|
||||
|
||||
20
.github/workflows/formbricks-release.yml
vendored
20
.github/workflows/formbricks-release.yml
vendored
@@ -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 }}
|
||||
|
||||
@@ -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,53 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
run: |
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
echo "Using SemVer tag: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_TAG"
|
||||
echo "Using tag as prerelease: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||
echo "Using branch as prerelease: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||
else
|
||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||
fi
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
@@ -83,6 +134,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 +163,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
|
||||
|
||||
15
.github/workflows/release-docker-github.yml
vendored
15
.github/workflows/release-docker-github.yml
vendored
@@ -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
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -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: |
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||
import { getUser, updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
OperationNotAllowedError,
|
||||
TooManyRequestsError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
||||
|
||||
const limiter = rateLimit({
|
||||
interval: 60 * 60, // 1 hour
|
||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
||||
});
|
||||
TUserPersonalInfoUpdateInput,
|
||||
TUserUpdateInput,
|
||||
ZUserPersonalInfoUpdateInput,
|
||||
} from "@formbricks/types/user";
|
||||
|
||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||
return {
|
||||
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
|
||||
parsedInput,
|
||||
payload,
|
||||
}: {
|
||||
ctx: any;
|
||||
parsedInput: any;
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
payload: TUserUpdateInput;
|
||||
}) {
|
||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||
|
||||
try {
|
||||
await limiter(ctx.user.id);
|
||||
} catch {
|
||||
throw new TooManyRequestsError("Too many requests");
|
||||
}
|
||||
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||
|
||||
if (ctx.user.identityProvider !== "email") {
|
||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||
}
|
||||
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
|
||||
return payload;
|
||||
}
|
||||
|
||||
export const updateUserAction = authenticatedActionClient
|
||||
.schema(
|
||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
||||
password: ZUserPassword.optional(),
|
||||
})
|
||||
)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: Record<string, any>;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"user",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: TUserPersonalInfoUpdateInput;
|
||||
}) => {
|
||||
const oldObject = await getUser(ctx.user.id);
|
||||
let payload = buildUserUpdatePayload(parsedInput);
|
||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
// Only proceed with updateUser if we have actual changes to make
|
||||
let newObject = oldObject;
|
||||
if (Object.keys(payload).length > 0) {
|
||||
newObject = await updateUser(ctx.user.id, payload);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||
ctx.auditLoggingCtx.newObject = newObject;
|
||||
|
||||
return true;
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZUpdateAvatarAction = z.object({
|
||||
avatarUrl: z.string(),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
|
||||
getResponseCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/display/service", () => ({
|
||||
getDisplayCountBySurveyId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
@@ -178,6 +183,7 @@ describe("ResponsesPage", () => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||
});
|
||||
@@ -206,6 +212,8 @@ describe("ResponsesPage", () => {
|
||||
isReadOnly: false,
|
||||
user: mockUser,
|
||||
publicDomain: mockPublicDomain,
|
||||
responseCount: 10,
|
||||
displayCount: 5,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
|
||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
@@ -40,6 +41,7 @@ const Page = async (props) => {
|
||||
|
||||
// Get response count for the CTA component
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const publicDomain = getPublicDomain();
|
||||
@@ -56,6 +58,7 @@ const Page = async (props) => {
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={responseCount}
|
||||
displayCount={displayCount}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { customAlphabet } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||
|
||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||
surveyId: ZId,
|
||||
@@ -202,6 +203,61 @@ export const deleteResultShareUrlAction = authenticatedActionClient
|
||||
)
|
||||
);
|
||||
|
||||
const ZResetSurveyAction = z.object({
|
||||
surveyId: ZId,
|
||||
organizationId: ZId,
|
||||
projectId: ZId,
|
||||
});
|
||||
|
||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
"survey",
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||
}) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: parsedInput.projectId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||
ctx.auditLoggingCtx.oldObject = null;
|
||||
|
||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||
parsedInput.surveyId
|
||||
);
|
||||
|
||||
ctx.auditLoggingCtx.newObject = {
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedResponsesCount: deletedResponsesCount,
|
||||
deletedDisplaysCount: deletedDisplaysCount,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const ZGetEmailHtmlAction = z.object({
|
||||
surveyId: ZId,
|
||||
});
|
||||
@@ -313,3 +369,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;
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { ShareEmbedSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareEmbedSurvey";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { BellRing, Eye, ListRestart, 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";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { resetSurveyAction } from "../actions";
|
||||
|
||||
interface SurveyAnalysisCTAProps {
|
||||
survey: TSurvey;
|
||||
@@ -26,16 +30,15 @@ interface SurveyAnalysisCTAProps {
|
||||
user: TUser;
|
||||
publicDomain: string;
|
||||
responseCount: number;
|
||||
displayCount: number;
|
||||
segments: TSegment[];
|
||||
isContactsEnabled: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
start: boolean;
|
||||
share: boolean;
|
||||
embed: boolean;
|
||||
panel: boolean;
|
||||
dropdown: boolean;
|
||||
}
|
||||
|
||||
export const SurveyAnalysisCTA = ({
|
||||
@@ -45,43 +48,48 @@ export const SurveyAnalysisCTA = ({
|
||||
user,
|
||||
publicDomain,
|
||||
responseCount,
|
||||
displayCount,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
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 [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||
const [isResetting, setIsResetting] = useState(false);
|
||||
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { organizationId, project } = useEnvironment();
|
||||
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,26 +110,45 @@ 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);
|
||||
|
||||
const handleResetSurvey = async () => {
|
||||
setIsResetting(true);
|
||||
const result = await resetSurveyAction({
|
||||
surveyId: survey.id,
|
||||
organizationId: organizationId,
|
||||
projectId: project.id,
|
||||
});
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("environments.surveys.summary.survey_reset_successfully", {
|
||||
responseCount: result.data.deletedResponsesCount,
|
||||
displayCount: result.data.deletedDisplaysCount,
|
||||
})
|
||||
);
|
||||
router.refresh();
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(result);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
setIsResetting(false);
|
||||
setIsResetModalOpen(false);
|
||||
};
|
||||
|
||||
const iconActions = [
|
||||
{
|
||||
icon: BellRing,
|
||||
@@ -132,9 +159,18 @@ 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",
|
||||
},
|
||||
{
|
||||
icon: ListRestart,
|
||||
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||
onClick: () => setIsResetModalOpen(true),
|
||||
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||
},
|
||||
{
|
||||
icon: SquarePenIcon,
|
||||
tooltip: t("common.edit"),
|
||||
@@ -164,32 +200,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
|
||||
@@ -204,6 +239,17 @@ export const SurveyAnalysisCTA = ({
|
||||
secondaryButtonText={t("common.edit")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
open={isResetModalOpen}
|
||||
setOpen={setIsResetModalOpen}
|
||||
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
|
||||
text={t("environments.surveys.summary.reset_survey_warning")}
|
||||
buttonText={t("environments.surveys.summary.reset_survey")}
|
||||
onConfirm={handleResetSurvey}
|
||||
buttonVariant="destructive"
|
||||
buttonLoading={isResetting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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&", "?")
|
||||
.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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
interface DisableLinkModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: "multi-use" | "single-use";
|
||||
onDisable: () => void;
|
||||
}
|
||||
|
||||
export const DisableLinkModal = ({ open, onOpenChange, type, onDisable }: DisableLinkModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent width="narrow" className="flex flex-col" hideCloseButton disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm font-medium text-slate-900">
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_title")
|
||||
: t("common.are_you_sure")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody>
|
||||
{type === "multi-use" ? (
|
||||
<>
|
||||
<p>
|
||||
{t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description")}
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
<p>
|
||||
{t(
|
||||
"environments.surveys.share.anonymous_links.disable_multi_use_link_modal_description_subtext"
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_description")}</p>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
onDisable();
|
||||
onOpenChange(false);
|
||||
}}>
|
||||
{type === "multi-use"
|
||||
? t("environments.surveys.share.anonymous_links.disable_multi_use_link_modal_button")
|
||||
: t("environments.surveys.share.anonymous_links.disable_single_use_link_modal_button")}
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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&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);
|
||||
|
||||
@@ -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&", "?")
|
||||
.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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
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}
|
||||
enforceSurveyUrlWidth
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { H3, Small } from "@/modules/ui/components/typography";
|
||||
|
||||
interface TabContainerProps {
|
||||
title: string;
|
||||
description: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabContainer = ({ title, description, children }: TabContainerProps) => {
|
||||
return (
|
||||
<div className="flex h-full grow flex-col items-start space-y-4">
|
||||
<div className="pb-2">
|
||||
<H3>{title}</H3>
|
||||
<Small color="muted" margin="headerDescription">
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
<div className="h-full w-full space-y-4 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
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 { WebsiteEmbedTab } from "./website-embed-tab";
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/modules/ui/components/advanced-option-toggle", () => ({
|
||||
AdvancedOptionToggle: (props: {
|
||||
htmlId: string;
|
||||
isChecked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
customContainerClass?: string;
|
||||
}) => (
|
||||
<div data-testid="advanced-option-toggle">
|
||||
<label htmlFor={props.htmlId}>{props.title}</label>
|
||||
<input
|
||||
id={props.htmlId}
|
||||
type="checkbox"
|
||||
checked={props.isChecked}
|
||||
onChange={(e) => props.onToggle(e.target.checked)}
|
||||
data-testid="embed-mode-toggle"
|
||||
/>
|
||||
<span>{props.description}</span>
|
||||
{props.customContainerClass && (
|
||||
<span data-testid="custom-container-class">{props.customContainerClass}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: (props: {
|
||||
title?: string;
|
||||
"aria-label"?: string;
|
||||
onClick?: () => void;
|
||||
children: React.ReactNode;
|
||||
type?: "button" | "submit" | "reset";
|
||||
}) => (
|
||||
<button
|
||||
title={props.title}
|
||||
aria-label={props["aria-label"]}
|
||||
onClick={props.onClick}
|
||||
data-testid="copy-button"
|
||||
type={props.type}>
|
||||
{props.children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/code-block", () => ({
|
||||
CodeBlock: (props: {
|
||||
language: string;
|
||||
showCopyToClipboard: boolean;
|
||||
noMargin?: boolean;
|
||||
children: string;
|
||||
}) => (
|
||||
<div data-testid="code-block">
|
||||
<span data-testid="language">{props.language}</span>
|
||||
<span data-testid="show-copy">{props.showCopyToClipboard?.toString() || "false"}</span>
|
||||
{props.noMargin && <span data-testid="no-margin">true</span>}
|
||||
<pre>{props.children}</pre>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
CopyIcon: () => <div data-testid="copy-icon">CopyIcon</div>,
|
||||
}));
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockImplementation(() => Promise.resolve()),
|
||||
},
|
||||
});
|
||||
|
||||
describe("WebsiteEmbedTab", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
surveyUrl: "https://example.com/survey/123",
|
||||
};
|
||||
|
||||
test("renders all components correctly", () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("code-block")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("advanced-option-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("copy-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct iframe code without embed mode", () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const codeBlock = screen.getByTestId("code-block");
|
||||
expect(codeBlock).toBeInTheDocument();
|
||||
|
||||
const code = codeBlock.querySelector("pre")?.textContent;
|
||||
expect(code).toContain(defaultProps.surveyUrl);
|
||||
expect(code).toContain("<iframe");
|
||||
expect(code).toContain('src="https://example.com/survey/123"');
|
||||
expect(code).not.toContain("?embed=true");
|
||||
});
|
||||
|
||||
test("renders correct iframe code with embed mode enabled", async () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByTestId("embed-mode-toggle");
|
||||
await userEvent.click(toggle);
|
||||
|
||||
const codeBlock = screen.getByTestId("code-block");
|
||||
const code = codeBlock.querySelector("pre")?.textContent;
|
||||
expect(code).toContain('src="https://example.com/survey/123?embed=true"');
|
||||
});
|
||||
|
||||
test("toggle changes embed mode state", async () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByTestId("embed-mode-toggle");
|
||||
expect(toggle).not.toBeChecked();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).toBeChecked();
|
||||
|
||||
await userEvent.click(toggle);
|
||||
expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("copy button copies iframe code to clipboard", async () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const copyButton = screen.getByTestId("copy-button");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining(defaultProps.surveyUrl)
|
||||
);
|
||||
const toast = await import("react-hot-toast");
|
||||
expect(toast.default.success).toHaveBeenCalledWith(
|
||||
"environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"
|
||||
);
|
||||
});
|
||||
|
||||
test("copy button copies correct code with embed mode enabled", async () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByTestId("embed-mode-toggle");
|
||||
await userEvent.click(toggle);
|
||||
|
||||
const copyButton = screen.getByTestId("copy-button");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("?embed=true"));
|
||||
});
|
||||
|
||||
test("renders code block with correct props", () => {
|
||||
render(<WebsiteEmbedTab {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("language")).toHaveTextContent("html");
|
||||
expect(screen.getByTestId("show-copy")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("no-margin")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
"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 { useTranslate } from "@tolgee/react";
|
||||
import { CopyIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface WebsiteEmbedTabProps {
|
||||
surveyUrl: string;
|
||||
}
|
||||
|
||||
export const WebsiteEmbedTab = ({ surveyUrl }: WebsiteEmbedTabProps) => {
|
||||
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 (
|
||||
<>
|
||||
<CodeBlock language="html" noMargin>
|
||||
{iframeCode}
|
||||
</CodeBlock>
|
||||
|
||||
<AdvancedOptionToggle
|
||||
htmlId="enableEmbedMode"
|
||||
isChecked={embedModeEnabled}
|
||||
onToggle={setEmbedModeEnabled}
|
||||
title={t("environments.surveys.share.embed_on_website.embed_mode")}
|
||||
description={t("environments.surveys.share.embed_on_website.embed_mode_description")}
|
||||
customContainerClass="pl-1 pr-0 py-0"
|
||||
/>
|
||||
<Button
|
||||
className="self-start"
|
||||
title={t("common.copy_code")}
|
||||
aria-label={t("common.copy_code")}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(iframeCode);
|
||||
toast.success(t("environments.surveys.share.embed_on_website.embed_code_copied_to_clipboard"));
|
||||
}}>
|
||||
{t("common.copy_code")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { useSurveyQRCode } from "./survey-qr-code";
|
||||
|
||||
// Mock QRCodeStyling
|
||||
const mockUpdate = vi.fn();
|
||||
const mockAppend = vi.fn();
|
||||
const mockDownload = vi.fn();
|
||||
vi.mock("qr-code-styling", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
update: mockUpdate,
|
||||
append: mockAppend,
|
||||
download: mockDownload,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe("useSurveyQRCode", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the DOM element for qrCodeRef before each test
|
||||
if (document.body.querySelector("#qr-code-test-div")) {
|
||||
document.body.removeChild(document.body.querySelector("#qr-code-test-div")!);
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.id = "qr-code-test-div";
|
||||
document.body.appendChild(div);
|
||||
});
|
||||
|
||||
test("should call toast.error if QRCodeStyling instantiation fails", () => {
|
||||
vi.mocked(QRCodeStyling).mockImplementationOnce(() => {
|
||||
throw new Error("QR Init failed");
|
||||
});
|
||||
renderHook(() => useSurveyQRCode("https://example.com/survey-error"));
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
|
||||
});
|
||||
|
||||
test("should call toast.error if QRCodeStyling update fails", () => {
|
||||
mockUpdate.mockImplementationOnce(() => {
|
||||
throw new Error("QR Update failed");
|
||||
});
|
||||
renderHook(() => useSurveyQRCode("https://example.com/survey-update-error"));
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
|
||||
});
|
||||
|
||||
test("should call toast.error if QRCodeStyling append fails", () => {
|
||||
mockAppend.mockImplementationOnce(() => {
|
||||
throw new Error("QR Append failed");
|
||||
});
|
||||
const { result } = renderHook(() => useSurveyQRCode("https://example.com/survey-append-error"));
|
||||
// Need to manually assign a div for the ref to trigger the append error path
|
||||
act(() => {
|
||||
result.current.qrCodeRef.current = document.createElement("div");
|
||||
});
|
||||
// Rerender to trigger useEffect after ref is set
|
||||
renderHook(() => useSurveyQRCode("https://example.com/survey-append-error"), { initialProps: result });
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
|
||||
});
|
||||
|
||||
test("should call toast.error if download fails", () => {
|
||||
const surveyUrl = "https://example.com/survey-download-error";
|
||||
const { result } = renderHook(() => useSurveyQRCode(surveyUrl));
|
||||
vi.mocked(QRCodeStyling).mockImplementationOnce(
|
||||
() =>
|
||||
({
|
||||
update: vi.fn(),
|
||||
append: vi.fn(),
|
||||
download: vi.fn(() => {
|
||||
throw new Error("Download failed");
|
||||
}),
|
||||
}) as any
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.downloadQRCode();
|
||||
});
|
||||
expect(toast.error).toHaveBeenCalledWith("environments.surveys.summary.failed_to_generate_qr_code");
|
||||
});
|
||||
|
||||
test("should not create new QRCodeStyling instance if one already exists for display", () => {
|
||||
const surveyUrl = "https://example.com/survey1";
|
||||
const { rerender } = renderHook(() => useSurveyQRCode(surveyUrl));
|
||||
expect(QRCodeStyling).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender(); // Rerender with same props
|
||||
expect(QRCodeStyling).toHaveBeenCalledTimes(1); // Should not create a new instance
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { getQRCodeOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/get-qr-code-options";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import QRCodeStyling from "qr-code-styling";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
|
||||
export const useSurveyQRCode = (surveyUrl: string) => {
|
||||
const qrCodeRef = useRef<HTMLDivElement>(null);
|
||||
const qrInstance = useRef<QRCodeStyling | null>(null);
|
||||
const { t } = useTranslate();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (!qrInstance.current) {
|
||||
qrInstance.current = new QRCodeStyling(getQRCodeOptions(70, 70));
|
||||
}
|
||||
|
||||
if (surveyUrl && qrInstance.current) {
|
||||
qrInstance.current.update({ data: surveyUrl });
|
||||
|
||||
if (qrCodeRef.current) {
|
||||
qrCodeRef.current.innerHTML = "";
|
||||
qrInstance.current.append(qrCodeRef.current);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
}, [surveyUrl, t]);
|
||||
|
||||
const downloadQRCode = () => {
|
||||
try {
|
||||
const downloadInstance = new QRCodeStyling(getQRCodeOptions(500, 500));
|
||||
downloadInstance.update({ data: surveyUrl });
|
||||
downloadInstance.download({ name: "survey-qr", extension: "png" });
|
||||
} catch (error) {
|
||||
toast.error(t("environments.surveys.summary.failed_to_generate_qr_code"));
|
||||
}
|
||||
};
|
||||
|
||||
return { qrCodeRef, downloadQRCode };
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("Deletes all responses and displays for a survey", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return the results directly
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 5 }, { count: 3 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 5,
|
||||
deletedDisplaysCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("Handles case with no responses or displays to delete", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
// Mock $transaction to return zero counts
|
||||
vi.mocked(prisma.$transaction).mockResolvedValue([{ count: 0 }, { count: 0 }]);
|
||||
|
||||
const result = await deleteResponsesAndDisplaysForSurvey(surveyId);
|
||||
|
||||
expect(result).toEqual({
|
||||
deletedResponsesCount: 0,
|
||||
deletedDisplaysCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("Throws a generic Error for other exceptions", async () => {
|
||||
const { prisma } = await import("@formbricks/database");
|
||||
|
||||
const mockErrorMessage = "Mock error message";
|
||||
vi.mocked(prisma.$transaction).mockRejectedValue(new Error(mockErrorMessage));
|
||||
|
||||
await expect(deleteResponsesAndDisplaysForSurvey(surveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const deleteResponsesAndDisplaysForSurvey = async (
|
||||
surveyId: string
|
||||
): Promise<{ deletedResponsesCount: number; deletedDisplaysCount: number }> => {
|
||||
try {
|
||||
// Delete all responses for this survey
|
||||
|
||||
const [deletedResponsesCount, deletedDisplaysCount] = await prisma.$transaction([
|
||||
prisma.response.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
prisma.display.deleteMany({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
deletedResponsesCount: deletedResponsesCount.count,
|
||||
deletedDisplaysCount: deletedDisplaysCount.count,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -58,6 +58,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
||||
user={user}
|
||||
publicDomain={publicDomain}
|
||||
responseCount={initialSurveySummary?.meta.totalResponses ?? 0}
|
||||
displayCount={initialSurveySummary?.meta.displayCount ?? 0}
|
||||
segments={segments}
|
||||
isContactsEnabled={isContactsEnabled}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum ShareViewType {
|
||||
ANON_LINKS = "anon-links",
|
||||
PERSONAL_LINKS = "personal-links",
|
||||
EMAIL = "email",
|
||||
WEBPAGE = "webpage",
|
||||
APP = "app",
|
||||
WEBSITE_EMBED = "website-embed",
|
||||
DYNAMIC_POPUP = "dynamic-popup",
|
||||
SOCIAL_MEDIA = "social-media",
|
||||
QR_CODE = "qr-code",
|
||||
}
|
||||
@@ -281,7 +281,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
? `${dateRange?.from ? format(dateRange?.from, "dd LLL") : "Select first date"} - ${
|
||||
dateRange?.to ? format(dateRange.to, "dd LLL") : "Select last date"
|
||||
}`
|
||||
: t(filterRange)}
|
||||
: filterRange}
|
||||
</span>
|
||||
{isFilterDropDownOpen ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
@@ -296,28 +296,28 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setFilterRange(getFilterDropDownLabels(t).ALL_TIME);
|
||||
setDateRange({ from: undefined, to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).ALL_TIME)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).ALL_TIME}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_7_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 7)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_7_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_7_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).LAST_30_DAYS);
|
||||
setDateRange({ from: startOfDay(subDays(new Date(), 30)), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_30_DAYS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_30_DAYS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_MONTH);
|
||||
setDateRange({ from: startOfMonth(new Date()), to: getTodayDate() });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -327,14 +327,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(subMonths(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_MONTH)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_MONTH}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_QUARTER);
|
||||
setDateRange({ from: startOfQuarter(new Date()), to: endOfQuarter(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -344,7 +344,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfQuarter(subQuarters(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_QUARTER)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_QUARTER}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -354,14 +354,14 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfMonth(getTodayDate()),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_6_MONTHS)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_6_MONTHS}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setFilterRange(getFilterDropDownLabels(t).THIS_YEAR);
|
||||
setDateRange({ from: startOfYear(new Date()), to: endOfYear(getTodayDate()) });
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).THIS_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).THIS_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -371,7 +371,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
to: endOfYear(subYears(getTodayDate(), 1)),
|
||||
});
|
||||
}}>
|
||||
<p className="text-slate-700">{t(getFilterDropDownLabels(t).LAST_YEAR)}</p>
|
||||
<p className="text-slate-700">{getFilterDropDownLabels(t).LAST_YEAR}</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
@@ -380,7 +380,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
|
||||
setSelectingDate(DateSelected.FROM);
|
||||
}}>
|
||||
<p className="text-sm text-slate-700 hover:ring-0">
|
||||
{t(getFilterDropDownLabels(t).CUSTOM_RANGE)}
|
||||
{getFilterDropDownLabels(t).CUSTOM_RANGE}
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -15,11 +15,13 @@ import { useTranslate } from "@tolgee/react";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AirplayIcon,
|
||||
ArrowUpFromDotIcon,
|
||||
CheckIcon,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ContactIcon,
|
||||
EyeOff,
|
||||
FlagIcon,
|
||||
GlobeIcon,
|
||||
GridIcon,
|
||||
HashIcon,
|
||||
@@ -89,8 +91,9 @@ const questionIcons = {
|
||||
device: SmartphoneIcon,
|
||||
os: AirplayIcon,
|
||||
browser: GlobeIcon,
|
||||
source: GlobeIcon,
|
||||
source: ArrowUpFromDotIcon,
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -132,10 +135,16 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
|
||||
return "bg-amber-500";
|
||||
}
|
||||
};
|
||||
|
||||
const getLabelStyle = (): string | undefined => {
|
||||
if (type !== OptionsType.META) return undefined;
|
||||
return label === "os" ? "uppercase" : "capitalize";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-[12rem] items-center sm:w-4/5">
|
||||
<span className={clsx("rounded-md p-1", getColor())}>{getIconType()}</span>
|
||||
<p className="ml-3 truncate text-sm text-slate-600">
|
||||
<p className={clsx("ml-3 truncate text-sm text-slate-600", getLabelStyle())}>
|
||||
{typeof label === "string" ? label : getLocalizedValue(label, "default")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { SurveyContextWrapper, useSurvey } from "./survey-context";
|
||||
|
||||
// Mock survey data
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "test-survey-id",
|
||||
createdAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
name: "Test Survey",
|
||||
type: "link",
|
||||
environmentId: "test-env-id",
|
||||
createdBy: "test-user-id",
|
||||
status: "draft",
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: { default: "Welcome" },
|
||||
html: { default: "" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
buttonLabel: { default: "Start" },
|
||||
fileUrl: undefined,
|
||||
videoUrl: undefined,
|
||||
},
|
||||
questions: [
|
||||
{
|
||||
id: "question-1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "What's your name?" },
|
||||
required: true,
|
||||
inputType: "text",
|
||||
logic: [],
|
||||
buttonLabel: { default: "Next" },
|
||||
backButtonLabel: { default: "Back" },
|
||||
placeholder: { default: "Enter your name" },
|
||||
longAnswer: false,
|
||||
subheader: { default: "" },
|
||||
charLimit: { enabled: false, min: 0, max: 255 },
|
||||
},
|
||||
],
|
||||
endings: [
|
||||
{
|
||||
id: "ending-1",
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
subheader: { default: "We appreciate your feedback." },
|
||||
buttonLabel: { default: "Done" },
|
||||
buttonLink: undefined,
|
||||
imageUrl: undefined,
|
||||
videoUrl: undefined,
|
||||
},
|
||||
],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
fieldIds: [],
|
||||
},
|
||||
variables: [],
|
||||
followUps: [],
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
showLanguageSwitch: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayPercentage: null,
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
alias: "English",
|
||||
projectId: "test-project-id",
|
||||
createdAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2023-01-01T00:00:00.000Z"),
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { survey } = useSurvey();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="survey-id">{survey.id}</div>
|
||||
<div data-testid="survey-name">{survey.name}</div>
|
||||
<div data-testid="survey-type">{survey.type}</div>
|
||||
<div data-testid="survey-status">{survey.status}</div>
|
||||
<div data-testid="survey-environment-id">{survey.environmentId}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("SurveyContext", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("provides survey data to child components", () => {
|
||||
render(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestComponent />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id");
|
||||
expect(screen.getByTestId("survey-name")).toHaveTextContent("Test Survey");
|
||||
expect(screen.getByTestId("survey-type")).toHaveTextContent("link");
|
||||
expect(screen.getByTestId("survey-status")).toHaveTextContent("draft");
|
||||
expect(screen.getByTestId("survey-environment-id")).toHaveTextContent("test-env-id");
|
||||
});
|
||||
|
||||
test("throws error when useSurvey is used outside of provider", () => {
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useSurvey();
|
||||
return <div>Should not render</div>;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow("useSurvey must be used within a SurveyContextWrapper");
|
||||
});
|
||||
|
||||
test("updates context value when survey changes", () => {
|
||||
const updatedSurvey = {
|
||||
...mockSurvey,
|
||||
name: "Updated Survey",
|
||||
status: "inProgress" as const,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestComponent />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("survey-name")).toHaveTextContent("Test Survey");
|
||||
expect(screen.getByTestId("survey-status")).toHaveTextContent("draft");
|
||||
|
||||
rerender(
|
||||
<SurveyContextWrapper survey={updatedSurvey}>
|
||||
<TestComponent />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("survey-name")).toHaveTextContent("Updated Survey");
|
||||
expect(screen.getByTestId("survey-status")).toHaveTextContent("inProgress");
|
||||
});
|
||||
|
||||
test("verifies memoization by tracking render counts", () => {
|
||||
let renderCount = 0;
|
||||
const renderSpy = vi.fn(() => {
|
||||
renderCount++;
|
||||
});
|
||||
|
||||
const TestComponentWithRenderTracking = () => {
|
||||
renderSpy();
|
||||
const { survey } = useSurvey();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="survey-id">{survey.id}</div>
|
||||
<div data-testid="render-count">{renderCount}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestComponentWithRenderTracking />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with the same survey object - should not trigger additional renders
|
||||
// if memoization is working correctly
|
||||
rerender(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestComponentWithRenderTracking />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("survey-id")).toHaveTextContent("test-survey-id");
|
||||
expect(renderSpy).toHaveBeenCalledTimes(2); // Should only be called once more for the rerender
|
||||
});
|
||||
|
||||
test("prevents unnecessary re-renders when survey object is unchanged", () => {
|
||||
const childRenderSpy = vi.fn();
|
||||
|
||||
const ChildComponent = () => {
|
||||
childRenderSpy();
|
||||
const { survey } = useSurvey();
|
||||
return <div data-testid="child-survey-name">{survey.name}</div>;
|
||||
};
|
||||
|
||||
const ParentComponent = ({ survey }: { survey: TSurvey }) => {
|
||||
return (
|
||||
<SurveyContextWrapper survey={survey}>
|
||||
<ChildComponent />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const { rerender } = render(<ParentComponent survey={mockSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Test Survey");
|
||||
expect(childRenderSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Rerender with the same survey object reference
|
||||
rerender(<ParentComponent survey={mockSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Test Survey");
|
||||
expect(childRenderSpy).toHaveBeenCalledTimes(2); // Should only be called once more
|
||||
|
||||
// Rerender with a different survey object should trigger re-render
|
||||
const updatedSurvey = { ...mockSurvey, name: "Updated Survey" };
|
||||
rerender(<ParentComponent survey={updatedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("child-survey-name")).toHaveTextContent("Updated Survey");
|
||||
expect(childRenderSpy).toHaveBeenCalledTimes(3); // Should be called again due to prop change
|
||||
});
|
||||
|
||||
test("renders children correctly", () => {
|
||||
const TestChild = () => <div data-testid="child">Child Component</div>;
|
||||
|
||||
render(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestChild />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Component");
|
||||
});
|
||||
|
||||
test("handles multiple child components", () => {
|
||||
const TestChild1 = () => {
|
||||
const { survey } = useSurvey();
|
||||
return <div data-testid="child-1">{survey.name}</div>;
|
||||
};
|
||||
|
||||
const TestChild2 = () => {
|
||||
const { survey } = useSurvey();
|
||||
return <div data-testid="child-2">{survey.type}</div>;
|
||||
};
|
||||
|
||||
render(
|
||||
<SurveyContextWrapper survey={mockSurvey}>
|
||||
<TestChild1 />
|
||||
<TestChild2 />
|
||||
</SurveyContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-1")).toHaveTextContent("Test Survey");
|
||||
expect(screen.getByTestId("child-2")).toHaveTextContent("link");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export interface SurveyContextType {
|
||||
survey: TSurvey;
|
||||
}
|
||||
|
||||
const SurveyContext = createContext<SurveyContextType | null>(null);
|
||||
SurveyContext.displayName = "SurveyContext";
|
||||
|
||||
export const useSurvey = () => {
|
||||
const context = useContext(SurveyContext);
|
||||
if (!context) {
|
||||
throw new Error("useSurvey must be used within a SurveyContextWrapper");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface SurveyContextWrapperProps {
|
||||
survey: TSurvey;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SurveyContextWrapper = ({ survey, children }: SurveyContextWrapperProps) => {
|
||||
const surveyContextValue = useMemo(
|
||||
() => ({
|
||||
survey,
|
||||
}),
|
||||
[survey]
|
||||
);
|
||||
|
||||
return <SurveyContext.Provider value={surveyContextValue}>{children}</SurveyContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,195 @@
|
||||
// Import the mocked function to access it in tests
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import SurveyLayout from "./layout";
|
||||
|
||||
// Mock the getSurvey function
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the SurveyContextWrapper component
|
||||
vi.mock("./context/survey-context", () => ({
|
||||
SurveyContextWrapper: ({ survey, children }: { survey: TSurvey; children: React.ReactNode }) => (
|
||||
<div data-testid="survey-context-wrapper" data-survey-id={survey.id}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("SurveyLayout", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey-123",
|
||||
name: "Test Survey",
|
||||
environmentId: "env-123",
|
||||
status: "inProgress",
|
||||
type: "link",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
questions: [],
|
||||
endings: [],
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
variables: [],
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: true,
|
||||
showResponseCount: false,
|
||||
},
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
autoClose: null,
|
||||
runOnDate: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
projectOverwrites: {
|
||||
brandColor: null,
|
||||
highlightBorderColor: null,
|
||||
placement: null,
|
||||
clickOutsideClose: null,
|
||||
darkOverlay: null,
|
||||
},
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
showLanguageSwitch: false,
|
||||
recaptcha: null,
|
||||
languages: [],
|
||||
triggers: [],
|
||||
segment: null,
|
||||
followUps: [],
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
const mockParams = Promise.resolve({
|
||||
surveyId: "survey-123",
|
||||
environmentId: "env-123",
|
||||
});
|
||||
|
||||
test("renders SurveyContextWrapper with survey and children when survey is found", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
|
||||
render(
|
||||
await SurveyLayout({
|
||||
params: mockParams,
|
||||
children: <div data-testid="test-children">Test Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith("survey-123");
|
||||
expect(screen.getByTestId("survey-context-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-context-wrapper")).toHaveAttribute("data-survey-id", "survey-123");
|
||||
expect(screen.getByTestId("test-children")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("throws error when survey is not found", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
SurveyLayout({
|
||||
params: mockParams,
|
||||
children: <div data-testid="test-children">Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Survey not found");
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith("survey-123");
|
||||
});
|
||||
|
||||
test("awaits params before calling getSurvey", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
|
||||
const delayedParams = new Promise<{ surveyId: string; environmentId: string }>((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
surveyId: "survey-456",
|
||||
environmentId: "env-456",
|
||||
});
|
||||
}, 10);
|
||||
});
|
||||
|
||||
render(
|
||||
await SurveyLayout({
|
||||
params: delayedParams,
|
||||
children: <div data-testid="test-children">Test Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith("survey-456");
|
||||
expect(screen.getByTestId("survey-context-wrapper")).toHaveAttribute("data-survey-id", "survey-123");
|
||||
});
|
||||
|
||||
test("calls getSurvey with correct surveyId from params", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
|
||||
const customParams = Promise.resolve({
|
||||
surveyId: "custom-survey-id",
|
||||
environmentId: "custom-env-id",
|
||||
});
|
||||
|
||||
render(
|
||||
await SurveyLayout({
|
||||
params: customParams,
|
||||
children: <div data-testid="test-children">Test Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith("custom-survey-id");
|
||||
expect(getSurvey).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("passes children to SurveyContextWrapper", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
|
||||
|
||||
const complexChildren = (
|
||||
<div data-testid="complex-children">
|
||||
<h1>Survey Title</h1>
|
||||
<p>Survey description</p>
|
||||
<button>Submit</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(
|
||||
await SurveyLayout({
|
||||
params: mockParams,
|
||||
children: complexChildren,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("complex-children")).toBeInTheDocument();
|
||||
expect(screen.getByText("Survey Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Survey description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Submit")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles getSurvey rejection correctly", async () => {
|
||||
const mockError = new Error("Database connection failed");
|
||||
vi.mocked(getSurvey).mockRejectedValue(mockError);
|
||||
|
||||
await expect(
|
||||
SurveyLayout({
|
||||
params: mockParams,
|
||||
children: <div data-testid="test-children">Test Content</div>,
|
||||
})
|
||||
).rejects.toThrow("Database connection failed");
|
||||
|
||||
expect(getSurvey).toHaveBeenCalledWith("survey-123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { SurveyContextWrapper } from "./context/survey-context";
|
||||
|
||||
interface SurveyLayoutProps {
|
||||
params: Promise<{ surveyId: string; environmentId: string }>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const SurveyLayout = async ({ params, children }: SurveyLayoutProps) => {
|
||||
const resolvedParams = await params;
|
||||
|
||||
const survey = await getSurvey(resolvedParams.surveyId);
|
||||
|
||||
if (!survey) {
|
||||
throw new Error("Survey not found");
|
||||
}
|
||||
|
||||
return <SurveyContextWrapper survey={survey}>{children}</SurveyContextWrapper>;
|
||||
};
|
||||
|
||||
export default SurveyLayout;
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user