mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-23 05:17:49 -05:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb17c22fc2 | |||
| 3d52f7b63b | |||
| e29a67b1f6 | |||
| 78f5de2f35 | |||
| 17222a59ef | |||
| 1ab856d2f0 | |||
| b1a35d4a69 | |||
| 2166c44470 | |||
| 080cf741e9 | |||
| 8881691509 | |||
| 3045f4437f | |||
| 91ace0e821 | |||
| 6ef281647a | |||
| 0aaaaa54ee | |||
| b1f78e7bf2 | |||
| 7086ce2ca3 | |||
| 8f8b549b1d | |||
| 28514487e0 | |||
| 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 |
@@ -18,7 +18,6 @@ apps/web/
|
|||||||
│ ├── (app)/ # Main application routes
|
│ ├── (app)/ # Main application routes
|
||||||
│ ├── (auth)/ # Authentication routes
|
│ ├── (auth)/ # Authentication routes
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
│ └── share/ # Public sharing routes
|
|
||||||
├── components/ # Shared components
|
├── components/ # Shared components
|
||||||
├── lib/ # Utility functions and services
|
├── lib/ # Utility functions and services
|
||||||
└── modules/ # Feature-specific modules
|
└── modules/ # Feature-specific modules
|
||||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
|||||||
### Dynamic Routes
|
### Dynamic Routes
|
||||||
- `[environmentId]` - Environment-specific routes
|
- `[environmentId]` - Environment-specific routes
|
||||||
- `[surveyId]` - Survey-specific routes
|
- `[surveyId]` - Survey-specific routes
|
||||||
- `[sharingKey]` - Public sharing routes
|
|
||||||
|
|
||||||
## Service Layer Pattern
|
## Service Layer Pattern
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Migrate deprecated UI components to a unified component
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
@@ -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.
|
||||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
|||||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test sharing mode
|
|
||||||
vi.mocked(useParams).mockReturnValue({
|
|
||||||
surveyId: "123",
|
|
||||||
sharingKey: "share-123"
|
|
||||||
});
|
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -189,15 +189,11 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
|
|
||||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
# 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
|
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)
|
# 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:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
# The below is used for Rate Limiting for management API
|
|
||||||
UNKEY_ROOT_KEY=
|
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# INTERCOM_APP_ID=
|
||||||
# INTERCOM_SECRET_KEY=
|
# INTERCOM_SECRET_KEY=
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||||
type: bug
|
type: bug
|
||||||
|
projects: "formbricks/8"
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: true
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://github.com/formbricks/formbricks/discussions
|
url: https://github.com/formbricks/formbricks/discussions
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ inputs:
|
|||||||
sentry_auth_token:
|
sentry_auth_token:
|
||||||
description: 'Sentry authentication token'
|
description: 'Sentry authentication token'
|
||||||
required: true
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Sentry environment (e.g., production, staging)'
|
||||||
|
required: false
|
||||||
|
default: 'staging'
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'composite'
|
using: 'composite'
|
||||||
@@ -107,7 +111,7 @@ runs:
|
|||||||
SENTRY_ORG: formbricks
|
SENTRY_ORG: formbricks
|
||||||
SENTRY_PROJECT: formbricks-cloud
|
SENTRY_PROJECT: formbricks-cloud
|
||||||
with:
|
with:
|
||||||
environment: production
|
environment: ${{ inputs.environment }}
|
||||||
version: ${{ inputs.release_version }}
|
version: ${{ inputs.release_version }}
|
||||||
sourcemaps: './extracted-next/'
|
sourcemaps: './extracted-next/'
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- stage
|
- staging
|
||||||
- prod
|
- production
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
@@ -52,6 +52,7 @@ jobs:
|
|||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
tags: tag:github
|
tags: tag:github
|
||||||
|
args: --accept-routes
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
- name: Configure AWS Credentials
|
||||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||||
@@ -66,8 +67,8 @@ jobs:
|
|||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@v2
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Prod
|
name: Deploy Formbricks Cloud Production
|
||||||
if: inputs.ENVIRONMENT == 'prod'
|
if: inputs.ENVIRONMENT == 'production'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -84,8 +85,8 @@ jobs:
|
|||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@v2
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Stage
|
name: Deploy Formbricks Cloud Staging
|
||||||
if: inputs.ENVIRONMENT == 'stage'
|
if: inputs.ENVIRONMENT == 'staging'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -101,13 +102,13 @@ jobs:
|
|||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- name: Purge Cloudflare Cache
|
- name: Purge Cloudflare Cache
|
||||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||||
env:
|
env:
|
||||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
# Set hostname based on environment
|
# Set hostname based on environment
|
||||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||||
PURGE_HOST="app.formbricks.com"
|
PURGE_HOST="app.formbricks.com"
|
||||||
else
|
else
|
||||||
PURGE_HOST="stage.app.formbricks.com"
|
PURGE_HOST="stage.app.formbricks.com"
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ jobs:
|
|||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_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 "" >> .env
|
||||||
echo "E2E_TESTING=1" >> .env
|
echo "E2E_TESTING=1" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -102,6 +103,12 @@ jobs:
|
|||||||
# pnpm prisma migrate deploy
|
# pnpm prisma migrate deploy
|
||||||
pnpm db:migrate:dev
|
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
|
- name: Check for Enterprise License
|
||||||
run: |
|
run: |
|
||||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
name: Build, release & deploy Formbricks images
|
name: Build, release & deploy Formbricks images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
release:
|
||||||
push:
|
types: [published]
|
||||||
tags:
|
|
||||||
- "v*"
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Build & release stable docker image
|
name: Build & release docker image
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
uses: ./.github/workflows/release-docker-github.yml
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
|
|
||||||
helm-chart-release:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
@@ -31,7 +36,7 @@ jobs:
|
|||||||
- helm-chart-release
|
- helm-chart-release
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
ENVIRONMENT: "prod"
|
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||||
|
|
||||||
upload-sentry-sourcemaps:
|
upload-sentry-sourcemaps:
|
||||||
name: Upload Sentry Sourcemaps
|
name: Upload Sentry Sourcemaps
|
||||||
@@ -54,3 +59,4 @@ jobs:
|
|||||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
environment: ${{ env.ENVIRONMENT }}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ jobs:
|
|||||||
# with sigstore/fulcio when running outside of PRs.
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||||
|
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
@@ -38,6 +42,53 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
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
|
- name: Set up Depot CLI
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||||
|
|
||||||
@@ -83,6 +134,21 @@ jobs:
|
|||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
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.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# 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
|
# 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
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
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
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ name: Docker Release to Github
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
IS_PRERELEASE:
|
||||||
|
description: 'Whether this is a prerelease (affects latest tag)'
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
outputs:
|
outputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
description: release version
|
description: release version
|
||||||
@@ -45,10 +51,12 @@ jobs:
|
|||||||
- name: Get Release Tag
|
- name: Get Release Tag
|
||||||
id: extract_release_tag
|
id: extract_release_tag
|
||||||
run: |
|
run: |
|
||||||
|
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||||
TAG=${{ github.ref }}
|
TAG=${{ github.ref }}
|
||||||
TAG=${TAG#refs/tags/v}
|
TAG=${TAG#refs/tags/v}
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Using tag-based version: $TAG"
|
||||||
|
|
||||||
- name: Update package.json version
|
- name: Update package.json version
|
||||||
run: |
|
run: |
|
||||||
@@ -81,6 +89,13 @@ jobs:
|
|||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
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)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ jobs:
|
|||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ jobs:
|
|||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${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/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* This function is used to resolve the absolute path of a package.
|
||||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||||
*/
|
*/
|
||||||
const getAbsolutePath = (value: string) => {
|
function getAbsolutePath(value: string): any {
|
||||||
return dirname(require.resolve(join(value, "package.json")));
|
return dirname(require.resolve(join(value, "package.json")));
|
||||||
};
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_POSTHOG_CONFIGURED: true,
|
IS_POSTHOG_CONFIGURED: true,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/env", () => ({
|
||||||
|
|||||||
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
alert: {
|
alert: {
|
||||||
...user.notificationSettings?.alert,
|
...user.notificationSettings?.alert,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
...user.notificationSettings?.weeklySummary,
|
|
||||||
[project.id]: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateUser(user.id, {
|
||||||
|
|||||||
+134
-3
@@ -9,8 +9,12 @@ import {
|
|||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import {
|
||||||
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
|
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
@@ -49,10 +53,14 @@ vi.mock("@/lib/membership/utils", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
getOrganizationProjectsLimit: vi.fn(),
|
||||||
|
getRoleManagementPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
getProjectPermissionByUserId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||||
|
getTeamsByOrganizationId: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/tolgee/server", () => ({
|
vi.mock("@/tolgee/server", () => ({
|
||||||
getTranslate: async () => (key: string) => key,
|
getTranslate: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||||
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
|
||||||
|
<div data-testid="main-navigation">
|
||||||
|
MainNavigation
|
||||||
|
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
||||||
|
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||||
@@ -104,7 +118,7 @@ const mockUser = {
|
|||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockOrganization = {
|
const mockOrganization = {
|
||||||
@@ -156,6 +170,17 @@ const mockProjectPermission = {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
const mockOrganizationTeams = [
|
||||||
|
{
|
||||||
|
id: "team-1",
|
||||||
|
name: "Development Team",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team-2",
|
||||||
|
name: "Marketing Team",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
|
|||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||||
|
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||||
mockIsDevelopment = false;
|
mockIsDevelopment = false;
|
||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes canDoRoleManagement props to MainNavigation", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||||
|
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty organizationTeams array", async () => {
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null organizationTeams", async () => {
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles canDoRoleManagement false", async () => {
|
||||||
|
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||||
|
});
|
||||||
|
|
||||||
test("throws error if user not found", async () => {
|
test("throws error if user not found", async () => {
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
vi.mocked(getUser).mockResolvedValue(null);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import {
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import {
|
||||||
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
@@ -48,9 +51,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
throw new Error(t("common.environment_not_found"));
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, environments] = await Promise.all([
|
const [projects, environments, canDoRoleManagement] = await Promise.all([
|
||||||
getUserProjects(user.id, organization.id),
|
getUserProjects(user.id, organization.id),
|
||||||
getEnvironments(environment.projectId),
|
getEnvironments(environment.projectId),
|
||||||
|
getRoleManagementPermission(organization.billing.plan),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
if (!projects || !environments || !organizations) {
|
||||||
@@ -117,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -52,9 +53,19 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|||||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
ProjectSwitcher: ({
|
||||||
|
isCollapsed,
|
||||||
|
organizationTeams,
|
||||||
|
canDoRoleManagement,
|
||||||
|
}: {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
organizationTeams: TOrganizationTeam[];
|
||||||
|
canDoRoleManagement: boolean;
|
||||||
|
}) => (
|
||||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||||
Project Switcher
|
Project Switcher
|
||||||
|
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
||||||
|
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
@@ -106,7 +117,7 @@ const mockUser = {
|
|||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
@@ -146,6 +157,7 @@ const defaultProps = {
|
|||||||
membershipRole: "owner" as const,
|
membershipRole: "owner" as const,
|
||||||
organizationProjectsLimit: 5,
|
organizationProjectsLimit: 5,
|
||||||
isLicenseActive: true,
|
isLicenseActive: true,
|
||||||
|
canDoRoleManagement: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
describe("MainNavigation", () => {
|
||||||
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||||
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles no organizationTeams", () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles canDoRoleManagement false", () => {
|
||||||
|
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ interface NavigationProps {
|
|||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
|
canDoRoleManagement: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -80,6 +81,7 @@ export const MainNavigation = ({
|
|||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
canDoRoleManagement,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -323,6 +325,7 @@ export const MainNavigation = ({
|
|||||||
isTextVisible={isTextVisible}
|
isTextVisible={isTextVisible}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+8
-8
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
|||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableHeaders = [
|
|
||||||
"common.survey",
|
|
||||||
"environments.integrations.airtable.table_name",
|
|
||||||
"common.questions",
|
|
||||||
"common.updated_at",
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||||
const { t } = useTranslate();
|
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 [isDeleting, setisDeleting] = useState(false);
|
||||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
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">
|
<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) => (
|
{tableHeaders.map((header) => (
|
||||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||||
{t(header)}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
-2
@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
-1
@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
-2
@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "mock-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
languages: [],
|
languages: [],
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
segment: null,
|
segment: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
|
|||||||
-2
@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
{
|
{
|
||||||
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
|
|||||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||||
hiddenFields: { enabled: true, fieldIds: [] },
|
hiddenFields: { enabled: true, fieldIds: [] },
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayLimit: null,
|
displayLimit: null,
|
||||||
} as unknown as TSurvey,
|
} as unknown as TSurvey,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
|
|||||||
languages: [],
|
languages: [],
|
||||||
styling: null,
|
styling: null,
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
closeOnDate: null,
|
closeOnDate: null,
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
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 { Session } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
@@ -13,12 +15,20 @@ import EnvLayout from "./layout";
|
|||||||
|
|
||||||
// Mock sub-components to render identifiable elements
|
// Mock sub-components to render identifiable elements
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentLayout", () => ({
|
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", () => ({
|
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||||
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
EnvironmentIdBaseLayout: ({ children, environmentId, session, user, organization }: any) => (
|
||||||
<div data-testid="EnvironmentIdBaseLayout">
|
<div
|
||||||
{environmentId}
|
data-testid="EnvironmentIdBaseLayout"
|
||||||
|
data-environment-id={environmentId}
|
||||||
|
data-session={session?.user?.id}
|
||||||
|
data-user={user?.id}
|
||||||
|
data-organization={organization?.id}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -27,7 +37,24 @@ vi.mock("@/modules/ui/components/toaster-client", () => ({
|
|||||||
ToasterClient: () => <div data-testid="ToasterClient" />,
|
ToasterClient: () => <div data-testid="ToasterClient" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("./components/EnvironmentStorageHandler", () => ({
|
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
|
// Mocks for dependencies
|
||||||
@@ -37,26 +64,43 @@ vi.mock("@/modules/environments/lib/utils", () => ({
|
|||||||
vi.mock("@/lib/project/service", () => ({
|
vi.mock("@/lib/project/service", () => ({
|
||||||
getProjectByEnvironmentId: vi.fn(),
|
getProjectByEnvironmentId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/environment/service", () => ({
|
||||||
|
getEnvironment: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
vi.mock("@/lib/membership/service", () => ({
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("EnvLayout", () => {
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders successfully when all dependencies return valid data", async () => {
|
test("renders successfully when all dependencies return valid data", async () => {
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
t: mockTranslation,
|
||||||
session: { user: { id: "user1" } } as Session,
|
session: mockSession,
|
||||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
user: mockUser,
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
organization: mockOrganization,
|
||||||
});
|
});
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(mockProject);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
vi.mocked(getEnvironment).mockResolvedValueOnce(mockEnvironment);
|
||||||
id: "member1",
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(mockMembership);
|
||||||
} as unknown as TMembership);
|
|
||||||
|
|
||||||
const result = await EnvLayout({
|
const result = await EnvLayout({
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
@@ -64,56 +108,43 @@ describe("EnvLayout", () => {
|
|||||||
});
|
});
|
||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
// Verify main layout structure
|
||||||
expect(screen.getByTestId("EnvironmentStorageHandler")).toHaveTextContent("env1");
|
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("EnvironmentLayout")).toBeDefined();
|
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");
|
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({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: ((key: string) => key) as any,
|
t: mockTranslation,
|
||||||
session: { user: { id: "user1" } } as Session,
|
session: null as unknown as Session,
|
||||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
user: mockUser,
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
organization: mockOrganization,
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce(null);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce({
|
|
||||||
id: "member1",
|
|
||||||
} as unknown as TMembership);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.project_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("throws error if membership is not found", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any,
|
|
||||||
session: { user: { id: "user1" } } as Session,
|
|
||||||
user: { id: "user1", email: "user1@example.com" } as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
|
||||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValueOnce({ id: "proj1" } as TProject);
|
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
EnvLayout({
|
|
||||||
params: Promise.resolve({ environmentId: "env1" }),
|
|
||||||
children: <div>Content</div>,
|
|
||||||
})
|
|
||||||
).rejects.toThrow("common.membership_not_found");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls redirect when session is null", async () => {
|
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
|
||||||
t: ((key: string) => key) as any,
|
|
||||||
session: undefined as unknown as Session,
|
|
||||||
user: undefined as unknown as TUser,
|
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
|
||||||
});
|
});
|
||||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||||
throw new Error("Redirect called");
|
throw new Error("Redirect called");
|
||||||
@@ -125,18 +156,16 @@ describe("EnvLayout", () => {
|
|||||||
children: <div>Content</div>,
|
children: <div>Content</div>,
|
||||||
})
|
})
|
||||||
).rejects.toThrow("Redirect called");
|
).rejects.toThrow("Redirect called");
|
||||||
|
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if user is null", async () => {
|
test("throws error if user is null", async () => {
|
||||||
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
t: ((key: string) => key) as any,
|
t: mockTranslation,
|
||||||
session: { user: { id: "user1" } } as Session,
|
session: mockSession,
|
||||||
user: undefined as unknown as TUser,
|
user: null as unknown as TUser,
|
||||||
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
organization: mockOrganization,
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(redirect).mockImplementationOnce(() => {
|
|
||||||
throw new Error("Redirect called");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -145,5 +174,154 @@ describe("EnvLayout", () => {
|
|||||||
children: <div>Content</div>,
|
children: <div>Content</div>,
|
||||||
})
|
})
|
||||||
).rejects.toThrow("common.user_not_found");
|
).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 { 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 { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
@@ -11,7 +13,6 @@ const EnvLayout = async (props: {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
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"));
|
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) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
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);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -42,9 +51,11 @@ const EnvLayout = async (props: {
|
|||||||
user={user}
|
user={user}
|
||||||
organization={organization}>
|
organization={organization}>
|
||||||
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
<EnvironmentStorageHandler environmentId={params.environmentId} />
|
||||||
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
<EnvironmentContextWrapper environment={environment} project={project}>
|
||||||
{children}
|
<EnvironmentLayout environmentId={params.environmentId} session={session}>
|
||||||
</EnvironmentLayout>
|
{children}
|
||||||
|
</EnvironmentLayout>
|
||||||
|
</EnvironmentContextWrapper>
|
||||||
</EnvironmentIdBaseLayout>
|
</EnvironmentIdBaseLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SENTRY_DSN: "mock-sentry-dsn",
|
SENTRY_DSN: "mock-sentry-dsn",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
-1
@@ -49,7 +49,6 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
|
|||||||
-166
@@ -1,166 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { Membership } from "../types";
|
|
||||||
import { EditWeeklySummary } from "./EditWeeklySummary";
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
UsersIcon: () => <div data-testid="users-icon" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
|
||||||
<a href={href} data-testid="link">
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockNotificationSwitch = vi.fn();
|
|
||||||
vi.mock("./NotificationSwitch", () => ({
|
|
||||||
NotificationSwitch: (props: any) => {
|
|
||||||
mockNotificationSwitch(props);
|
|
||||||
return (
|
|
||||||
<div data-testid={`notification-switch-${props.surveyOrProjectOrOrganizationId}`}>
|
|
||||||
NotificationSwitch
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockT = vi.fn((key) => key);
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: mockT,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockUser = {
|
|
||||||
id: "user1",
|
|
||||||
name: "Test User",
|
|
||||||
email: "test@example.com",
|
|
||||||
notificationSettings: {
|
|
||||||
alert: {},
|
|
||||||
weeklySummary: {
|
|
||||||
proj1: true,
|
|
||||||
proj3: false,
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: [],
|
|
||||||
},
|
|
||||||
role: "project_manager",
|
|
||||||
objective: "other",
|
|
||||||
emailVerified: new Date(),
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
identityProvider: "email",
|
|
||||||
twoFactorEnabled: false,
|
|
||||||
} as unknown as TUser;
|
|
||||||
|
|
||||||
const mockMemberships: Membership[] = [
|
|
||||||
{
|
|
||||||
organization: {
|
|
||||||
id: "org1",
|
|
||||||
name: "Organization 1",
|
|
||||||
projects: [
|
|
||||||
{ id: "proj1", name: "Project 1", environments: [] },
|
|
||||||
{ id: "proj2", name: "Project 2", environments: [] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
organization: {
|
|
||||||
id: "org2",
|
|
||||||
name: "Organization 2",
|
|
||||||
projects: [{ id: "proj3", name: "Project 3", environments: [] }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const environmentId = "test-env-id";
|
|
||||||
|
|
||||||
describe("EditWeeklySummary", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly with multiple memberships and projects", () => {
|
|
||||||
render(<EditWeeklySummary memberships={mockMemberships} user={mockUser} environmentId={environmentId} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Organization 1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project 1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project 2")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Organization 2")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project 3")).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
surveyOrProjectOrOrganizationId: "proj1",
|
|
||||||
notificationSettings: mockUser.notificationSettings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("notification-switch-proj1")).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
surveyOrProjectOrOrganizationId: "proj2",
|
|
||||||
notificationSettings: mockUser.notificationSettings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("notification-switch-proj2")).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(mockNotificationSwitch).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
surveyOrProjectOrOrganizationId: "proj3",
|
|
||||||
notificationSettings: mockUser.notificationSettings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("notification-switch-proj3")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const inviteLinks = screen.getAllByTestId("link");
|
|
||||||
expect(inviteLinks.length).toBe(mockMemberships.length);
|
|
||||||
inviteLinks.forEach((link) => {
|
|
||||||
expect(link).toHaveAttribute("href", `/environments/${environmentId}/settings/general`);
|
|
||||||
expect(link).toHaveTextContent("common.invite_them");
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getAllByTestId("users-icon").length).toBe(mockMemberships.length);
|
|
||||||
|
|
||||||
expect(screen.getAllByText("common.project")[0]).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("common.weekly_summary")[0]).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getAllByText("environments.settings.notifications.want_to_loop_in_organization_mates?").length
|
|
||||||
).toBe(mockMemberships.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly with no memberships", () => {
|
|
||||||
render(<EditWeeklySummary memberships={[]} user={mockUser} environmentId={environmentId} />);
|
|
||||||
expect(screen.queryByText("Organization 1")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("users-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly when an organization has no projects", () => {
|
|
||||||
const membershipsWithNoProjects: Membership[] = [
|
|
||||||
{
|
|
||||||
organization: {
|
|
||||||
id: "org3",
|
|
||||||
name: "Organization No Projects",
|
|
||||||
projects: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
render(
|
|
||||||
<EditWeeklySummary
|
|
||||||
memberships={membershipsWithNoProjects}
|
|
||||||
user={mockUser}
|
|
||||||
environmentId={environmentId}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
expect(screen.getByText("Organization No Projects")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("Project 1")).not.toBeInTheDocument(); // Check that no projects are listed under it
|
|
||||||
expect(mockNotificationSwitch).not.toHaveBeenCalled(); // No projects, so no switches for projects
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { UsersIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
import { Membership } from "../types";
|
|
||||||
import { NotificationSwitch } from "./NotificationSwitch";
|
|
||||||
|
|
||||||
interface EditAlertsProps {
|
|
||||||
memberships: Membership[];
|
|
||||||
user: TUser;
|
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EditWeeklySummary = ({ memberships, user, environmentId }: EditAlertsProps) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{memberships.map((membership) => (
|
|
||||||
<div key={membership.organization.id}>
|
|
||||||
<div className="mb-5 flex items-center space-x-3 text-sm font-medium">
|
|
||||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
|
||||||
|
|
||||||
<p className="text-slate-800">{membership.organization.name}</p>
|
|
||||||
</div>
|
|
||||||
<div className="mb-6 rounded-lg border border-slate-200">
|
|
||||||
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
|
||||||
<div className="col-span-2">{t("common.project")}</div>
|
|
||||||
<div className="col-span-1 text-center">{t("common.weekly_summary")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 p-2">
|
|
||||||
{membership.organization.projects.map((project) => (
|
|
||||||
<div
|
|
||||||
className="grid h-auto w-full cursor-pointer grid-cols-3 place-content-center justify-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
|
|
||||||
key={project.id}>
|
|
||||||
<div className="col-span-2">{project?.name}</div>
|
|
||||||
<div className="col-span-1 flex items-center justify-center">
|
|
||||||
<NotificationSwitch
|
|
||||||
surveyOrProjectOrOrganizationId={project.id}
|
|
||||||
notificationSettings={user.notificationSettings!}
|
|
||||||
notificationType={"weeklySummary"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="pb-3 pl-4 text-xs text-slate-400">
|
|
||||||
{t("environments.settings.notifications.want_to_loop_in_organization_mates")}?{" "}
|
|
||||||
<Link className="font-semibold" href={`/environments/${environmentId}/settings/general`}>
|
|
||||||
{t("common.invite_them")}
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-39
@@ -29,7 +29,6 @@ const organizationId = "org1";
|
|||||||
|
|
||||||
const baseNotificationSettings: TUserNotificationSettings = {
|
const baseNotificationSettings: TUserNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(switchInput.checked).toBe(false);
|
expect(switchInput.checked).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders with initial checked state for 'weeklySummary' (true)", () => {
|
|
||||||
const settings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
|
||||||
renderSwitch({
|
|
||||||
surveyOrProjectOrOrganizationId: projectId,
|
|
||||||
notificationSettings: settings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
});
|
|
||||||
const switchInput = screen.getByLabelText(
|
|
||||||
"toggle notification settings for weeklySummary"
|
|
||||||
) as HTMLInputElement;
|
|
||||||
expect(switchInput.checked).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
test("renders with initial checked state for 'unsubscribedOrganizationIds' (subscribed initially, so checked is true)", () => {
|
||||||
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
|
||||||
renderSwitch({
|
renderSwitch({
|
||||||
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
|
|||||||
expect(toast.success).not.toHaveBeenCalled();
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction fails for 'weeklySummary' type", async () => {
|
|
||||||
const mockErrorResponse = { serverError: "Database connection failed" };
|
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
|
||||||
|
|
||||||
const initialSettings = { ...baseNotificationSettings, weeklySummary: { [projectId]: true } };
|
|
||||||
renderSwitch({
|
|
||||||
surveyOrProjectOrOrganizationId: projectId,
|
|
||||||
notificationSettings: initialSettings,
|
|
||||||
notificationType: "weeklySummary",
|
|
||||||
});
|
|
||||||
const switchInput = screen.getByLabelText("toggle notification settings for weeklySummary");
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
await user.click(switchInput);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(updateNotificationSettingsAction).toHaveBeenCalledWith({
|
|
||||||
notificationSettings: { ...initialSettings, weeklySummary: { [projectId]: false } },
|
|
||||||
});
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Database connection failed", {
|
|
||||||
id: "notification-switch",
|
|
||||||
});
|
|
||||||
expect(toast.success).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
test("shows error toast when updateNotificationSettingsAction fails for 'unsubscribedOrganizationIds' type", async () => {
|
||||||
const mockErrorResponse = { serverError: "Permission denied" };
|
const mockErrorResponse = { serverError: "Permission denied" };
|
||||||
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
|
|||||||
interface NotificationSwitchProps {
|
interface NotificationSwitchProps {
|
||||||
surveyOrProjectOrOrganizationId: string;
|
surveyOrProjectOrOrganizationId: string;
|
||||||
notificationSettings: TUserNotificationSettings;
|
notificationSettings: TUserNotificationSettings;
|
||||||
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
|
notificationType: "alert" | "unsubscribedOrganizationIds";
|
||||||
autoDisableNotificationType?: string;
|
autoDisableNotificationType?: string;
|
||||||
autoDisableNotificationElementId?: string;
|
autoDisableNotificationElementId?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
-12
@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
|
|||||||
.getByText("environments.settings.notifications.email_alerts_surveys")
|
.getByText("environments.settings.notifications.email_alerts_surveys")
|
||||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
.closest("div[class*='rounded-xl']"); // Find parent card
|
||||||
expect(alertsCard).toBeInTheDocument();
|
expect(alertsCard).toBeInTheDocument();
|
||||||
|
|
||||||
// Check for Weekly Summary LoadingCard
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
const weeklySummaryCard = screen
|
|
||||||
.getByText("environments.settings.notifications.weekly_summary_projects")
|
|
||||||
.closest("div[class*='rounded-xl']"); // Find parent card
|
|
||||||
expect(weeklySummaryCard).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
-5
@@ -14,11 +14,6 @@ const Loading = () => {
|
|||||||
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
description: t("environments.settings.notifications.set_up_an_alert_to_get_an_email_on_new_responses"),
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: t("environments.settings.notifications.weekly_summary_projects"),
|
|
||||||
description: t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday"),
|
|
||||||
skeletonLines: [{ classes: "h-6 w-28" }, { classes: "h-10 w-128" }, { classes: "h-10 w-128" }],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+1
-31
@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { Membership } from "./types";
|
import { Membership } from "./types";
|
||||||
|
|
||||||
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
vi.mock("./components/EditAlerts", () => ({
|
vi.mock("./components/EditAlerts", () => ({
|
||||||
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
|
||||||
}));
|
}));
|
||||||
vi.mock("./components/EditWeeklySummary", () => ({
|
|
||||||
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
|
|
||||||
}));
|
|
||||||
vi.mock("./components/IntegrationsTip", () => ({
|
vi.mock("./components/IntegrationsTip", () => ({
|
||||||
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
|
||||||
}));
|
}));
|
||||||
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: { "survey-old": true },
|
alert: { "survey-old": true },
|
||||||
weeklySummary: { "project-old": true },
|
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
|
expect(screen.getByText("IntegrationsTipComponent")).toBeInTheDocument();
|
||||||
expect(
|
|
||||||
screen.getByText("environments.settings.notifications.weekly_summary_projects")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
// The actual `user.notificationSettings` passed to EditAlerts will be a new object
|
||||||
// after `setCompleteNotificationSettings` processes it.
|
// after `setCompleteNotificationSettings` processes it.
|
||||||
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
|
|||||||
// It iterates memberships, then projects, then environments, then surveys.
|
// It iterates memberships, then projects, then environments, then surveys.
|
||||||
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
// `newNotificationSettings.alert[survey.id] = notificationSettings[survey.id]?.responseFinished || (notificationSettings.alert && notificationSettings.alert[survey.id]) || false;`
|
||||||
// This means only survey IDs found in memberships will be in the new `alert` object.
|
// This means only survey IDs found in memberships will be in the new `alert` object.
|
||||||
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
|
|
||||||
|
|
||||||
const finalExpectedSettings = {
|
const finalExpectedSettings = {
|
||||||
alert: {
|
alert: {
|
||||||
"survey-1": false,
|
"survey-1": false,
|
||||||
"survey-2": false,
|
"survey-2": false,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
"project-1": false,
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
unsubscribedOrganizationIds: ["org-unsubscribed"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
|
|||||||
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
|
||||||
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
|
||||||
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
expect(editAlertsCall.autoDisableNotificationElementId).toBe(mockSearchParams.elementId);
|
||||||
|
|
||||||
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
|
||||||
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(finalExpectedSettings);
|
|
||||||
expect(editWeeklySummaryCall.memberships).toEqual(mockMemberships);
|
|
||||||
expect(editWeeklySummaryCall.environmentId).toBe(mockParams.environmentId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if session is not found", async () => {
|
test("throws error if session is not found", async () => {
|
||||||
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
|
|||||||
render(PageComponent);
|
render(PageComponent);
|
||||||
|
|
||||||
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
|
||||||
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const expectedEmptySettings = {
|
const expectedEmptySettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
|
||||||
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
||||||
expect(editAlertsCall.memberships).toEqual([]);
|
expect(editAlertsCall.memberships).toEqual([]);
|
||||||
|
|
||||||
const editWeeklySummaryCall = vi.mocked(EditWeeklySummary).mock.calls[0][0];
|
|
||||||
expect(editWeeklySummaryCall.user.notificationSettings).toEqual(expectedEmptySettings);
|
|
||||||
expect(editWeeklySummaryCall.memberships).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles legacy notification settings correctly", async () => {
|
test("handles legacy notification settings correctly", async () => {
|
||||||
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
|
|||||||
id: "user-legacy",
|
id: "user-legacy",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
|
||||||
weeklySummary: { "project-1": true },
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
} as any, // To allow legacy structure
|
} as any, // To allow legacy structure
|
||||||
};
|
};
|
||||||
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
|
|||||||
"survey-1": true, // Should be true due to legacy setting
|
"survey-1": true, // Should be true due to legacy setting
|
||||||
"survey-2": false, // Default for other surveys in membership
|
"survey-2": false, // Default for other surveys in membership
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
"project-1": true, // From user's weeklySummary
|
|
||||||
},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
-10
@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
|
|||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { EditAlerts } from "./components/EditAlerts";
|
import { EditAlerts } from "./components/EditAlerts";
|
||||||
import { EditWeeklySummary } from "./components/EditWeeklySummary";
|
|
||||||
import { IntegrationsTip } from "./components/IntegrationsTip";
|
import { IntegrationsTip } from "./components/IntegrationsTip";
|
||||||
import type { Membership } from "./types";
|
import type { Membership } from "./types";
|
||||||
|
|
||||||
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
|
|||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings = {
|
const newNotificationSettings = {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
for (const project of membership.organization.projects) {
|
for (const project of membership.organization.projects) {
|
||||||
// set default values for weekly summary
|
|
||||||
newNotificationSettings.weeklySummary[project.id] =
|
|
||||||
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[project.id]) || false;
|
|
||||||
// set default values for alerts
|
// set default values for alerts
|
||||||
for (const environment of project.environments) {
|
for (const environment of project.environments) {
|
||||||
for (const survey of environment.surveys) {
|
for (const survey of environment.surveys) {
|
||||||
@@ -183,11 +178,6 @@ const Page = async (props) => {
|
|||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IntegrationsTip environmentId={params.environmentId} />
|
<IntegrationsTip environmentId={params.environmentId} />
|
||||||
<SettingsCard
|
|
||||||
title={t("environments.settings.notifications.weekly_summary_projects")}
|
|
||||||
description={t("environments.settings.notifications.stay_up_to_date_with_a_Weekly_every_Monday")}>
|
|
||||||
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
|
||||||
</SettingsCard>
|
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+38
-52
@@ -10,24 +10,19 @@ import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
|||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import {
|
import {
|
||||||
AuthenticationError,
|
TUserPersonalInfoUpdateInput,
|
||||||
AuthorizationError,
|
TUserUpdateInput,
|
||||||
OperationNotAllowedError,
|
ZUserPersonalInfoUpdateInput,
|
||||||
TooManyRequestsError,
|
} from "@formbricks/types/user";
|
||||||
} from "@formbricks/types/errors";
|
|
||||||
import { TUserUpdateInput, ZUserPassword, ZUserUpdateInput } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
const limiter = rateLimit({
|
|
||||||
interval: 60 * 60, // 1 hour
|
|
||||||
allowedPerInterval: 3, // max 3 calls for email verification per hour
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
@@ -41,18 +36,15 @@ async function handleEmailUpdate({
|
|||||||
parsedInput,
|
parsedInput,
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
ctx: any;
|
ctx: AuthenticatedActionClientCtx;
|
||||||
parsedInput: any;
|
parsedInput: TUserPersonalInfoUpdateInput;
|
||||||
payload: TUserUpdateInput;
|
payload: TUserUpdateInput;
|
||||||
}) {
|
}) {
|
||||||
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
const inputEmail = parsedInput.email?.trim().toLowerCase();
|
||||||
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
if (!inputEmail || ctx.user.email === inputEmail) return payload;
|
||||||
|
|
||||||
try {
|
await applyRateLimit(rateLimitConfigs.actions.emailUpdate, ctx.user.id);
|
||||||
await limiter(ctx.user.id);
|
|
||||||
} catch {
|
|
||||||
throw new TooManyRequestsError("Too many requests");
|
|
||||||
}
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
if (ctx.user.identityProvider !== "email") {
|
||||||
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
throw new OperationNotAllowedError("Email update is not allowed for non-credential users.");
|
||||||
}
|
}
|
||||||
@@ -75,41 +67,35 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient
|
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
||||||
.schema(
|
withAuditLogging(
|
||||||
ZUserUpdateInput.pick({ name: true, email: true, locale: true }).extend({
|
"updated",
|
||||||
password: ZUserPassword.optional(),
|
"user",
|
||||||
})
|
async ({
|
||||||
)
|
ctx,
|
||||||
.action(
|
parsedInput,
|
||||||
withAuditLogging(
|
}: {
|
||||||
"updated",
|
ctx: AuthenticatedActionClientCtx;
|
||||||
"user",
|
parsedInput: TUserPersonalInfoUpdateInput;
|
||||||
async ({
|
}) => {
|
||||||
ctx,
|
const oldObject = await getUser(ctx.user.id);
|
||||||
parsedInput,
|
let payload = buildUserUpdatePayload(parsedInput);
|
||||||
}: {
|
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: Record<string, any>;
|
|
||||||
}) => {
|
|
||||||
const oldObject = await getUser(ctx.user.id);
|
|
||||||
let payload = buildUserUpdatePayload(parsedInput);
|
|
||||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
|
||||||
|
|
||||||
// Only proceed with updateUser if we have actual changes to make
|
// Only proceed with updateUser if we have actual changes to make
|
||||||
let newObject = oldObject;
|
let newObject = oldObject;
|
||||||
if (Object.keys(payload).length > 0) {
|
if (Object.keys(payload).length > 0) {
|
||||||
newObject = await updateUser(ctx.user.id, payload);
|
newObject = await updateUser(ctx.user.id, payload);
|
||||||
}
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = newObject;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
);
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = newObject;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const ZUpdateAvatarAction = z.object({
|
const ZUpdateAvatarAction = z.object({
|
||||||
avatarUrl: z.string(),
|
avatarUrl: z.string(),
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ const mockUser = {
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ const mockUser = {
|
|||||||
locale: "en-US",
|
locale: "en-US",
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
alert: {},
|
alert: {},
|
||||||
weeklySummary: {},
|
|
||||||
unsubscribedOrganizationIds: [],
|
unsubscribedOrganizationIds: [],
|
||||||
},
|
},
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@ const mockUser = {
|
|||||||
imageUrl: "http://example.com/avatar.png",
|
imageUrl: "http://example.com/avatar.png",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ const mockUser = {
|
|||||||
imageUrl: "",
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SMTP_USER: "mock-smtp-user",
|
SMTP_USER: "mock-smtp-user",
|
||||||
SMTP_PASSWORD: "mock-smtp-password",
|
SMTP_PASSWORD: "mock-smtp-password",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { H3, Small } from "@/modules/ui/components/typography";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
|
||||||
export const SettingsCard = ({
|
export const SettingsCard = ({
|
||||||
@@ -31,7 +32,7 @@ export const SettingsCard = ({
|
|||||||
id={title}>
|
id={title}>
|
||||||
<div className="border-b border-slate-200 px-4 pb-4">
|
<div className="border-b border-slate-200 px-4 pb-4">
|
||||||
<div className="flex">
|
<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">
|
<div className="ml-2">
|
||||||
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
{beta && <Badge size="normal" type="warning" text="Beta" />}
|
||||||
{soon && (
|
{soon && (
|
||||||
@@ -39,7 +40,9 @@ export const SettingsCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
<Small color="muted" margin="headerDescription">
|
||||||
|
{description}
|
||||||
|
</Small>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-19
@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
SMTP_USER: "mock-smtp-user",
|
SMTP_USER: "mock-smtp-user",
|
||||||
SMTP_PASSWORD: "mock-smtp-password",
|
SMTP_PASSWORD: "mock-smtp-password",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
|
|||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
|
||||||
vi.mock("@/app/lib/surveys/surveys");
|
vi.mock("@/app/lib/surveys/surveys");
|
||||||
vi.mock("@/app/share/[sharingKey]/actions");
|
|
||||||
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
|
||||||
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
|
||||||
}));
|
}));
|
||||||
@@ -112,7 +111,6 @@ const mockSurvey = {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
|
||||||
segment: null,
|
segment: null,
|
||||||
resultShareKey: null,
|
|
||||||
closeOnDate: null,
|
closeOnDate: null,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
autoComplete: null,
|
autoComplete: null,
|
||||||
@@ -171,22 +169,6 @@ describe("SurveyAnalysisNavigation", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders navigation correctly for sharing page", () => {
|
|
||||||
mockUsePathname.mockReturnValue(
|
|
||||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/summary`
|
|
||||||
);
|
|
||||||
mockUseParams.mockReturnValue({ sharingKey: "test-sharing-key" });
|
|
||||||
mockUseResponseFilter.mockReturnValue({ selectedFilter: "all", dateRange: {} } as any);
|
|
||||||
mockGetFormattedFilters.mockReturnValue([] as any);
|
|
||||||
mockGetResponseCountAction.mockResolvedValue({ data: 5 });
|
|
||||||
|
|
||||||
render(<SurveyAnalysisNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(MockSecondaryNavigation).toHaveBeenCalled();
|
|
||||||
const lastCallArgs = MockSecondaryNavigation.mock.calls[MockSecondaryNavigation.mock.calls.length - 1][0];
|
|
||||||
expect(lastCallArgs.navigation[0].href).toContain("/share/test-sharing-key");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("displays correct response count string in label for various scenarios", async () => {
|
test("displays correct response count string in label for various scenarios", async () => {
|
||||||
mockUsePathname.mockReturnValue(
|
mockUsePathname.mockReturnValue(
|
||||||
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`
|
||||||
|
|||||||
+2
-5
@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
|
|||||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { InboxIcon, PresentationIcon } from "lucide-react";
|
import { InboxIcon, PresentationIcon } from "lucide-react";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
|
|
||||||
interface SurveyAnalysisNavigationProps {
|
interface SurveyAnalysisNavigationProps {
|
||||||
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
|
|||||||
}: SurveyAnalysisNavigationProps) => {
|
}: SurveyAnalysisNavigationProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const params = useParams();
|
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const url = isSharingPage ? `/share/${sharingKey}` : `/environments/${environmentId}/surveys/${survey.id}`;
|
const url = `/environments/${environmentId}/surveys/${survey.id}`;
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{
|
{
|
||||||
|
|||||||
-1
@@ -50,7 +50,6 @@ const mockSurvey = {
|
|||||||
isBackButtonHidden: false,
|
isBackButtonHidden: false,
|
||||||
pin: null,
|
pin: null,
|
||||||
recontactDays: null,
|
recontactDays: null,
|
||||||
resultShareKey: null,
|
|
||||||
runOnDate: null,
|
runOnDate: null,
|
||||||
showLanguageSwitch: false,
|
showLanguageSwitch: false,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
|
|||||||
+1
-2
@@ -113,7 +113,6 @@ const mockSurvey = {
|
|||||||
singleUse: null,
|
singleUse: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
|
||||||
styling: null,
|
styling: null,
|
||||||
@@ -139,7 +138,7 @@ const mockUser = {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "increase_conversion",
|
objective: "increase_conversion",
|
||||||
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockEnvironmentTags: TTag[] = [
|
const mockEnvironmentTags: TTag[] = [
|
||||||
|
|||||||
-1
@@ -88,7 +88,6 @@ const mockSurvey = {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
|||||||
-34
@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
|||||||
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
|
CustomFilter: vi.fn(() => <div data-testid="custom-filter">CustomFilter</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
|
||||||
ResultsShareButton: vi.fn(() => <div data-testid="results-share-button">ResultsShareButton</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/lib/surveys/surveys", () => ({
|
vi.mock("@/app/lib/surveys/surveys", () => ({
|
||||||
getFormattedFilters: vi.fn(),
|
getFormattedFilters: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
|
||||||
getResponseCountBySurveySharingKeyAction: vi.fn(),
|
|
||||||
getResponsesBySurveySharingKeyAction: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/utils/recall", () => ({
|
vi.mock("@/lib/utils/recall", () => ({
|
||||||
replaceHeadlineRecall: vi.fn((survey) => survey),
|
replaceHeadlineRecall: vi.fn((survey) => survey),
|
||||||
}));
|
}));
|
||||||
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
|
|||||||
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
|
||||||
.getResponseCountAction
|
.getResponseCountAction
|
||||||
);
|
);
|
||||||
const mockGetResponsesBySurveySharingKeyAction = vi.mocked(
|
|
||||||
(await import("@/app/share/[sharingKey]/actions")).getResponsesBySurveySharingKeyAction
|
|
||||||
);
|
|
||||||
const mockGetResponseCountBySurveySharingKeyAction = vi.mocked(
|
|
||||||
(await import("@/app/share/[sharingKey]/actions")).getResponseCountBySurveySharingKeyAction
|
|
||||||
);
|
|
||||||
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
|
const mockUseParams = vi.mocked((await import("next/navigation")).useParams);
|
||||||
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
const mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
|
||||||
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
|
||||||
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
|
|||||||
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
|
||||||
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
|
||||||
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
|
||||||
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
|
|
||||||
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
|
|
||||||
mockGetFormattedFilters.mockReturnValue({});
|
mockGetFormattedFilters.mockReturnValue({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
|
|||||||
render(<ResponsePage {...defaultProps} />);
|
render(<ResponsePage {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(mockGetResponsesAction).toHaveBeenCalled();
|
expect(mockGetResponsesAction).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not render ResultsShareButton when isReadOnly is true", async () => {
|
|
||||||
render(<ResponsePage {...defaultProps} isReadOnly={true} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render ResultsShareButton when on sharing page", async () => {
|
|
||||||
mockUseParams.mockReturnValue({ sharingKey: "share123" });
|
|
||||||
render(<ResponsePage {...defaultProps} />);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(mockGetResponsesBySurveySharingKeyAction).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("fetches next page of responses", async () => {
|
test("fetches next page of responses", async () => {
|
||||||
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
const { rerender } = render(<ResponsePage {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
+17
-47
@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
|
|||||||
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
import { getResponsesAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||||
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
import { ResponseDataView } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseDataView";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
import { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
@@ -20,7 +18,6 @@ interface ResponsePageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
responsesPerPage: number;
|
responsesPerPage: number;
|
||||||
@@ -32,17 +29,12 @@ export const ResponsePage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
|
||||||
user,
|
user,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
responsesPerPage,
|
responsesPerPage,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
const params = useParams();
|
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const [responses, setResponses] = useState<TResponse[]>([]);
|
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
@@ -63,30 +55,20 @@ export const ResponsePage = ({
|
|||||||
|
|
||||||
let newResponses: TResponse[] = [];
|
let newResponses: TResponse[] = [];
|
||||||
|
|
||||||
if (isSharingPage) {
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey: sharingKey,
|
limit: responsesPerPage,
|
||||||
limit: responsesPerPage,
|
offset: (newPage - 1) * responsesPerPage,
|
||||||
offset: (newPage - 1) * responsesPerPage,
|
filterCriteria: filters,
|
||||||
filterCriteria: filters,
|
});
|
||||||
});
|
newResponses = getResponsesActionResponse?.data || [];
|
||||||
newResponses = getResponsesActionResponse?.data || [];
|
|
||||||
} else {
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
|
||||||
surveyId,
|
|
||||||
limit: responsesPerPage,
|
|
||||||
offset: (newPage - 1) * responsesPerPage,
|
|
||||||
filterCriteria: filters,
|
|
||||||
});
|
|
||||||
newResponses = getResponsesActionResponse?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
setResponses([...responses, ...newResponses]);
|
setResponses([...responses, ...newResponses]);
|
||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
|
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||||
|
|
||||||
const deleteResponses = (responseIds: string[]) => {
|
const deleteResponses = (responseIds: string[]) => {
|
||||||
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||||
@@ -114,25 +96,14 @@ export const ResponsePage = ({
|
|||||||
setFetchingFirstPage(true);
|
setFetchingFirstPage(true);
|
||||||
let responses: TResponse[] = [];
|
let responses: TResponse[] = [];
|
||||||
|
|
||||||
if (isSharingPage) {
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey,
|
limit: responsesPerPage,
|
||||||
limit: responsesPerPage,
|
offset: 0,
|
||||||
offset: 0,
|
filterCriteria: filters,
|
||||||
filterCriteria: filters,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
responses = getResponsesActionResponse?.data || [];
|
responses = getResponsesActionResponse?.data || [];
|
||||||
} else {
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
|
||||||
surveyId,
|
|
||||||
limit: responsesPerPage,
|
|
||||||
offset: 0,
|
|
||||||
filterCriteria: filters,
|
|
||||||
});
|
|
||||||
|
|
||||||
responses = getResponsesActionResponse?.data || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responses.length < responsesPerPage) {
|
if (responses.length < responsesPerPage) {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
@@ -143,7 +114,7 @@ export const ResponsePage = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchInitialResponses();
|
fetchInitialResponses();
|
||||||
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
|
}, [surveyId, filters, responsesPerPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
@@ -155,7 +126,6 @@ export const ResponsePage = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
|
|
||||||
</div>
|
</div>
|
||||||
<ResponseDataView
|
<ResponseDataView
|
||||||
survey={survey}
|
survey={survey}
|
||||||
|
|||||||
-1
@@ -156,7 +156,6 @@ const mockSurvey = {
|
|||||||
projectOverwrites: null,
|
projectOverwrites: null,
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
pin: null,
|
pin: null,
|
||||||
resultShareKey: null,
|
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
welcomeCard: {
|
welcomeCard: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
+8
-3
@@ -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 { 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 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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
|
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
|
|||||||
getResponseCountBySurveyId: vi.fn(),
|
getResponseCountBySurveyId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/display/service", () => ({
|
||||||
|
getDisplayCountBySurveyId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/survey/service", () => ({
|
vi.mock("@/lib/survey/service", () => ({
|
||||||
getSurvey: vi.fn(),
|
getSurvey: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -115,7 +120,6 @@ vi.mock("next/navigation", () => ({
|
|||||||
useParams: () => ({
|
useParams: () => ({
|
||||||
environmentId: "test-env-id",
|
environmentId: "test-env-id",
|
||||||
surveyId: "test-survey-id",
|
surveyId: "test-survey-id",
|
||||||
sharingKey: null,
|
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -178,6 +182,7 @@ describe("ResponsesPage", () => {
|
|||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
|
||||||
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
|
||||||
|
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
|
||||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||||
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
|
||||||
});
|
});
|
||||||
@@ -206,6 +211,8 @@ describe("ResponsesPage", () => {
|
|||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
publicDomain: mockPublicDomain,
|
publicDomain: mockPublicDomain,
|
||||||
|
responseCount: 10,
|
||||||
|
displayCount: 5,
|
||||||
}),
|
}),
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
@@ -224,12 +231,10 @@ describe("ResponsesPage", () => {
|
|||||||
environment: mockEnvironment,
|
environment: mockEnvironment,
|
||||||
survey: mockSurvey,
|
survey: mockSurvey,
|
||||||
surveyId: mockSurveyId,
|
surveyId: mockSurveyId,
|
||||||
publicDomain: mockPublicDomain,
|
|
||||||
environmentTags: mockTags,
|
environmentTags: mockTags,
|
||||||
user: mockUser,
|
user: mockUser,
|
||||||
responsesPerPage: 10,
|
responsesPerPage: 10,
|
||||||
locale: mockLocale,
|
locale: mockLocale,
|
||||||
isReadOnly: false,
|
|
||||||
}),
|
}),
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -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 { 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 { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
|
||||||
|
import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
@@ -40,6 +41,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
// Get response count for the CTA component
|
// Get response count for the CTA component
|
||||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||||
|
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
@@ -56,6 +58,7 @@ const Page = async (props) => {
|
|||||||
user={user}
|
user={user}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
|
displayCount={displayCount}
|
||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
@@ -67,7 +70,6 @@ const Page = async (props) => {
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
survey={survey}
|
survey={survey}
|
||||||
surveyId={params.surveyId}
|
surveyId={params.surveyId}
|
||||||
publicDomain={publicDomain}
|
|
||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
|
|||||||
+86
-130
@@ -14,10 +14,10 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
|
|||||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
|
||||||
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
|
||||||
import { customAlphabet } from "nanoid";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
|
||||||
|
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
|
||||||
|
|
||||||
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
@@ -64,143 +64,60 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ZGenerateResultShareUrlAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
|
organizationId: ZId,
|
||||||
|
projectId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const generateResultShareUrlAction = authenticatedActionClient
|
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
||||||
.schema(ZGenerateResultShareUrlAction)
|
withAuditLogging(
|
||||||
.action(
|
"updated",
|
||||||
withAuditLogging(
|
"survey",
|
||||||
"updated",
|
async ({
|
||||||
"survey",
|
ctx,
|
||||||
async ({
|
parsedInput,
|
||||||
ctx,
|
}: {
|
||||||
parsedInput,
|
ctx: AuthenticatedActionClientCtx;
|
||||||
}: {
|
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
||||||
ctx: AuthenticatedActionClientCtx;
|
}) => {
|
||||||
parsedInput: Record<string, any>;
|
await checkAuthorizationUpdated({
|
||||||
}) => {
|
userId: ctx.user.id,
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
organizationId: parsedInput.organizationId,
|
||||||
await checkAuthorizationUpdated({
|
access: [
|
||||||
userId: ctx.user.id,
|
{
|
||||||
organizationId: organizationId,
|
type: "organization",
|
||||||
access: [
|
roles: ["owner", "manager"],
|
||||||
{
|
},
|
||||||
type: "organization",
|
{
|
||||||
roles: ["owner", "manager"],
|
type: "projectTeam",
|
||||||
},
|
minPermission: "readWrite",
|
||||||
{
|
projectId: parsedInput.projectId,
|
||||||
type: "projectTeam",
|
},
|
||||||
minPermission: "readWrite",
|
],
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
});
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const survey = await getSurvey(parsedInput.surveyId);
|
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
||||||
if (!survey) {
|
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
ctx.auditLoggingCtx.oldObject = null;
|
||||||
}
|
|
||||||
|
|
||||||
const resultShareKey = customAlphabet(
|
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
|
parsedInput.surveyId
|
||||||
20
|
);
|
||||||
)();
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
ctx.auditLoggingCtx.newObject = {
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
ctx.auditLoggingCtx.oldObject = survey;
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
|
};
|
||||||
|
|
||||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
|
return {
|
||||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
success: true,
|
||||||
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
return resultShareKey;
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
}
|
};
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ZGetResultShareUrlAction = z.object({
|
|
||||||
surveyId: ZId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getResultShareUrlAction = authenticatedActionClient
|
|
||||||
.schema(ZGetResultShareUrlAction)
|
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
)
|
||||||
return survey.resultShareKey;
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const ZDeleteResultShareUrlAction = z.object({
|
|
||||||
surveyId: ZId,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteResultShareUrlAction = authenticatedActionClient
|
|
||||||
.schema(ZDeleteResultShareUrlAction)
|
|
||||||
.action(
|
|
||||||
withAuditLogging(
|
|
||||||
"updated",
|
|
||||||
"survey",
|
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: Record<string, any>;
|
|
||||||
}) => {
|
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
minPermission: "readWrite",
|
|
||||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const survey = await getSurvey(parsedInput.surveyId);
|
|
||||||
if (!survey) {
|
|
||||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
|
||||||
ctx.auditLoggingCtx.oldObject = survey;
|
|
||||||
|
|
||||||
const newSurvey = await updateSurvey({ ...survey, resultShareKey: null });
|
|
||||||
ctx.auditLoggingCtx.newObject = newSurvey;
|
|
||||||
|
|
||||||
return newSurvey;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const ZGetEmailHtmlAction = z.object({
|
const ZGetEmailHtmlAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
@@ -313,3 +230,42 @@ export const generatePersonalLinksAction = authenticatedActionClient
|
|||||||
count: csvData.length,
|
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;
|
||||||
|
});
|
||||||
|
|||||||
-150
@@ -1,150 +0,0 @@
|
|||||||
import { ShareSurveyResults } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ShareSurveyResults";
|
|
||||||
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";
|
|
||||||
|
|
||||||
// Mock Button
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
|
||||||
Button: vi.fn(({ children, onClick, asChild, ...props }: any) => {
|
|
||||||
if (asChild) {
|
|
||||||
// For 'asChild', Button renders its children, potentially passing props via Slot.
|
|
||||||
// Mocking simply renders children inside a div that can receive Button's props.
|
|
||||||
return <div {...props}>{children}</div>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button onClick={onClick} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Dialog
|
|
||||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
|
||||||
Dialog: vi.fn(({ children, open, onOpenChange }) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="dialog" role="dialog">
|
|
||||||
{children}
|
|
||||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
),
|
|
||||||
DialogContent: vi.fn(({ children, ...props }) => (
|
|
||||||
<div data-testid="dialog-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
DialogBody: vi.fn(({ children }) => <div data-testid="dialog-body">{children}</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock useTranslate
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: vi.fn(() => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Next Link
|
|
||||||
vi.mock("next/link", () => ({
|
|
||||||
default: vi.fn(({ children, href, target, rel, ...props }) => (
|
|
||||||
<a href={href} target={target} rel={rel} {...props}>
|
|
||||||
{children}
|
|
||||||
</a>
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock react-hot-toast
|
|
||||||
vi.mock("react-hot-toast", () => ({
|
|
||||||
toast: {
|
|
||||||
success: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockSetOpen = vi.fn();
|
|
||||||
const mockHandlePublish = vi.fn();
|
|
||||||
const mockHandleUnpublish = vi.fn();
|
|
||||||
const surveyUrl = "https://app.formbricks.com/s/some-survey-id";
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
open: true,
|
|
||||||
setOpen: mockSetOpen,
|
|
||||||
handlePublish: mockHandlePublish,
|
|
||||||
handleUnpublish: mockHandleUnpublish,
|
|
||||||
showPublishModal: false,
|
|
||||||
surveyUrl: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ShareSurveyResults", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Mock navigator.clipboard
|
|
||||||
Object.defineProperty(global.navigator, "clipboard", {
|
|
||||||
value: {
|
|
||||||
writeText: vi.fn(() => Promise.resolve()),
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders publish warning when showPublishModal is false", async () => {
|
|
||||||
render(<ShareSurveyResults {...defaultProps} />);
|
|
||||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.surveys.summary.publish_to_web_warning_description")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
const publishButton = screen.getByText("environments.surveys.summary.publish_to_web");
|
|
||||||
expect(publishButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(publishButton);
|
|
||||||
expect(mockHandlePublish).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders survey public info when showPublishModal is true and surveyUrl is provided", async () => {
|
|
||||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl={surveyUrl} />);
|
|
||||||
expect(screen.getByText("environments.surveys.summary.survey_results_are_public")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(surveyUrl)).toBeInTheDocument();
|
|
||||||
|
|
||||||
const copyButton = screen.getByRole("button", { name: "Copy survey link to clipboard" });
|
|
||||||
expect(copyButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(copyButton);
|
|
||||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(surveyUrl);
|
|
||||||
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("common.link_copied");
|
|
||||||
|
|
||||||
const unpublishButton = screen.getByText("environments.surveys.summary.unpublish_from_web");
|
|
||||||
expect(unpublishButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(unpublishButton);
|
|
||||||
expect(mockHandleUnpublish).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const viewSiteLink = screen.getByText("environments.surveys.summary.view_site");
|
|
||||||
expect(viewSiteLink).toBeInTheDocument();
|
|
||||||
const anchor = viewSiteLink.closest("a");
|
|
||||||
expect(anchor).toHaveAttribute("href", surveyUrl);
|
|
||||||
expect(anchor).toHaveAttribute("target", "_blank");
|
|
||||||
expect(anchor).toHaveAttribute("rel", "noopener noreferrer");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render content when modal is closed (open is false)", () => {
|
|
||||||
render(<ShareSurveyResults {...defaultProps} open={false} />);
|
|
||||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("environments.surveys.summary.publish_to_web_warning")).not.toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders publish warning if surveyUrl is empty even if showPublishModal is true", () => {
|
|
||||||
render(<ShareSurveyResults {...defaultProps} showPublishModal={true} surveyUrl="" />);
|
|
||||||
expect(screen.getByText("environments.surveys.summary.publish_to_web_warning")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.queryByText("environments.surveys.summary.survey_results_are_public")
|
|
||||||
).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-97
@@ -1,97 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Dialog, DialogBody, DialogContent } from "@/modules/ui/components/dialog";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { AlertCircleIcon, CheckCircle2Icon } from "lucide-react";
|
|
||||||
import { Clipboard } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
|
|
||||||
interface ShareEmbedSurveyProps {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
handlePublish: () => void;
|
|
||||||
handleUnpublish: () => void;
|
|
||||||
showPublishModal: boolean;
|
|
||||||
surveyUrl: string;
|
|
||||||
}
|
|
||||||
export const ShareSurveyResults = ({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
handlePublish,
|
|
||||||
handleUnpublish,
|
|
||||||
showPublishModal,
|
|
||||||
surveyUrl,
|
|
||||||
}: ShareEmbedSurveyProps) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogBody>
|
|
||||||
{showPublishModal && surveyUrl ? (
|
|
||||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
|
||||||
<CheckCircle2Icon className="text-primary h-20 w-20" />
|
|
||||||
<div>
|
|
||||||
<p className="text-primary text-lg font-medium">
|
|
||||||
{t("environments.surveys.summary.survey_results_are_public")}
|
|
||||||
</p>
|
|
||||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
|
||||||
{t("environments.surveys.summary.survey_results_are_shared_with_anyone_who_has_the_link")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="whitespace-nowrap rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-800">
|
|
||||||
<span>{surveyUrl}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
title="Copy survey link to clipboard"
|
|
||||||
aria-label="Copy survey link to clipboard"
|
|
||||||
className="hover:cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
navigator.clipboard.writeText(surveyUrl);
|
|
||||||
toast.success(t("common.link_copied"));
|
|
||||||
}}>
|
|
||||||
<Clipboard />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="secondary"
|
|
||||||
className="text-center"
|
|
||||||
onClick={() => handleUnpublish()}>
|
|
||||||
{t("environments.surveys.summary.unpublish_from_web")}
|
|
||||||
</Button>
|
|
||||||
<Button className="text-center" asChild>
|
|
||||||
<Link href={surveyUrl} target="_blank" rel="noopener noreferrer">
|
|
||||||
{t("environments.surveys.summary.view_site")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col rounded-2xl bg-white p-8">
|
|
||||||
<div className="flex flex-col items-center gap-y-6 text-center">
|
|
||||||
<AlertCircleIcon className="h-20 w-20 text-slate-300" />
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-medium text-slate-600">
|
|
||||||
{t("environments.surveys.summary.publish_to_web_warning")}
|
|
||||||
</p>
|
|
||||||
<p className="text-balanced mt-2 text-sm text-slate-500">
|
|
||||||
{t("environments.surveys.summary.publish_to_web_warning_description")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" className="h-full text-center" onClick={() => handlePublish()}>
|
|
||||||
{t("environments.surveys.summary.publish_to_web")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DialogBody>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-1
@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
|
|||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
hiddenFields: { enabled: false, fieldIds: [] },
|
hiddenFields: { enabled: false, fieldIds: [] },
|
||||||
variables: [],
|
variables: [],
|
||||||
resultShareKey: null,
|
|
||||||
displayPercentage: null,
|
displayPercentage: null,
|
||||||
} as unknown as TSurvey;
|
} as unknown as TSurvey;
|
||||||
|
|
||||||
|
|||||||
-1
@@ -177,7 +177,6 @@ const mockSurvey = {
|
|||||||
autoClose: null,
|
autoClose: null,
|
||||||
triggers: [],
|
triggers: [],
|
||||||
languages: [],
|
languages: [],
|
||||||
resultShareKey: null,
|
|
||||||
singleUse: null,
|
singleUse: null,
|
||||||
styling: null,
|
styling: null,
|
||||||
surveyClosedMessage: null,
|
surveyClosedMessage: null,
|
||||||
|
|||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
@@ -118,13 +117,13 @@ export const SummaryMetadata = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
{!isLoading && (
|
{!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 ? (
|
{showDropOffs ? (
|
||||||
<ChevronUpIcon className="h-4 w-4" />
|
<ChevronUpIcon className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-54
@@ -44,43 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/share/[sharingKey]/actions", () => ({
|
|
||||||
getResponseCountBySurveySharingKeyAction: vi.fn().mockResolvedValue({ data: 42 }),
|
|
||||||
getSummaryBySurveySharingKeyAction: vi.fn().mockResolvedValue({
|
|
||||||
data: {
|
|
||||||
meta: {
|
|
||||||
completedPercentage: 80,
|
|
||||||
completedResponses: 40,
|
|
||||||
displayCount: 50,
|
|
||||||
dropOffPercentage: 20,
|
|
||||||
dropOffCount: 10,
|
|
||||||
startsPercentage: 100,
|
|
||||||
totalResponses: 50,
|
|
||||||
ttcAverage: 120,
|
|
||||||
},
|
|
||||||
dropOff: [
|
|
||||||
{
|
|
||||||
questionId: "q1",
|
|
||||||
headline: "Question 1",
|
|
||||||
questionType: "openText",
|
|
||||||
ttc: 20000,
|
|
||||||
impressions: 50,
|
|
||||||
dropOffCount: 5,
|
|
||||||
dropOffPercentage: 10,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
summary: [
|
|
||||||
{
|
|
||||||
question: { id: "q1", headline: "Question 1", type: "openText", required: true },
|
|
||||||
responseCount: 45,
|
|
||||||
type: "openText",
|
|
||||||
samples: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock(
|
vi.mock(
|
||||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs",
|
||||||
@@ -125,10 +88,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
|
|||||||
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
CustomFilter: () => <div data-testid="custom-filter">Custom Filter</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
|
|
||||||
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock context
|
// Mock context
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
|
||||||
useResponseFilter: () => ({
|
useResponseFilter: () => ({
|
||||||
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
|
|||||||
webAppUrl: "https://app.example.com",
|
webAppUrl: "https://app.example.com",
|
||||||
totalResponseCount: 50,
|
totalResponseCount: 50,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test("renders loading state initially", () => {
|
test("renders loading state initially", () => {
|
||||||
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
|
|||||||
// Drop-offs should now be visible
|
// Drop-offs should now be visible
|
||||||
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("doesn't show share button in read-only mode", async () => {
|
|
||||||
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
|
|
||||||
|
|
||||||
// Wait for loading to complete
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-26
@@ -5,11 +5,9 @@ import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]
|
|||||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||||
import { ResultsShareButton } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton";
|
|
||||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||||
import { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
|
|
||||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||||
@@ -36,9 +34,7 @@ interface SummaryPageProps {
|
|||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
publicDomain: string;
|
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isReadOnly: boolean;
|
|
||||||
initialSurveySummary?: TSurveySummary;
|
initialSurveySummary?: TSurveySummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,15 +42,9 @@ export const SummaryPage = ({
|
|||||||
environment,
|
environment,
|
||||||
survey,
|
survey,
|
||||||
surveyId,
|
surveyId,
|
||||||
publicDomain,
|
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
|
||||||
initialSurveySummary,
|
initialSurveySummary,
|
||||||
}: SummaryPageProps) => {
|
}: SummaryPageProps) => {
|
||||||
const params = useParams();
|
|
||||||
const sharingKey = params.sharingKey as string;
|
|
||||||
const isSharingPage = !!sharingKey;
|
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||||
@@ -87,17 +77,10 @@ export const SummaryPage = ({
|
|||||||
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
|
||||||
let updatedSurveySummary;
|
let updatedSurveySummary;
|
||||||
|
|
||||||
if (isSharingPage) {
|
updatedSurveySummary = await getSurveySummaryAction({
|
||||||
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
|
surveyId,
|
||||||
sharingKey,
|
filterCriteria: currentFilters,
|
||||||
filterCriteria: currentFilters,
|
});
|
||||||
});
|
|
||||||
} else {
|
|
||||||
updatedSurveySummary = await getSurveySummaryAction({
|
|
||||||
surveyId,
|
|
||||||
filterCriteria: currentFilters,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
|
||||||
setSurveySummary(surveySummary);
|
setSurveySummary(surveySummary);
|
||||||
@@ -109,7 +92,7 @@ export const SummaryPage = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
fetchSummary();
|
fetchSummary();
|
||||||
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
|
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
|
||||||
|
|
||||||
const surveyMemoized = useMemo(() => {
|
const surveyMemoized = useMemo(() => {
|
||||||
return replaceHeadlineRecall(survey, "default");
|
return replaceHeadlineRecall(survey, "default");
|
||||||
@@ -132,9 +115,6 @@ export const SummaryPage = ({
|
|||||||
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
<CustomFilter survey={surveyMemoized} />
|
<CustomFilter survey={surveyMemoized} />
|
||||||
{!isReadOnly && !isSharingPage && (
|
|
||||||
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<ScrollToTop containerId="mainContent" />
|
<ScrollToTop containerId="mainContent" />
|
||||||
<SummaryList
|
<SummaryList
|
||||||
|
|||||||
+971
-337
File diff suppressed because it is too large
Load Diff
+78
-24
@@ -1,23 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage";
|
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 { 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 { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
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 { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||||
import { IconBar } from "@/modules/ui/components/iconbar";
|
import { IconBar } from "@/modules/ui/components/iconbar";
|
||||||
import { useTranslate } from "@tolgee/react";
|
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 { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSegment } from "@formbricks/types/segment";
|
import { TSegment } from "@formbricks/types/segment";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { resetSurveyAction } from "../actions";
|
||||||
|
|
||||||
interface SurveyAnalysisCTAProps {
|
interface SurveyAnalysisCTAProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
@@ -26,6 +29,7 @@ interface SurveyAnalysisCTAProps {
|
|||||||
user: TUser;
|
user: TUser;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
responseCount: number;
|
responseCount: number;
|
||||||
|
displayCount: number;
|
||||||
segments: TSegment[];
|
segments: TSegment[];
|
||||||
isContactsEnabled: boolean;
|
isContactsEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
@@ -43,22 +47,25 @@ export const SurveyAnalysisCTA = ({
|
|||||||
user,
|
user,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
responseCount,
|
responseCount,
|
||||||
|
displayCount,
|
||||||
segments,
|
segments,
|
||||||
isContactsEnabled,
|
isContactsEnabled,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
}: SurveyAnalysisCTAProps) => {
|
}: SurveyAnalysisCTAProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [modalState, setModalState] = useState<ModalState>({
|
const [modalState, setModalState] = useState<ModalState>({
|
||||||
start: searchParams.get("share") === "true",
|
start: searchParams.get("share") === "true",
|
||||||
share: false,
|
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, isReadOnly);
|
||||||
|
|
||||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
|
|
||||||
@@ -71,12 +78,16 @@ export const SurveyAnalysisCTA = ({
|
|||||||
|
|
||||||
const handleShareModalToggle = (open: boolean) => {
|
const handleShareModalToggle = (open: boolean) => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (open) {
|
const currentShareParam = params.get("share") === "true";
|
||||||
|
|
||||||
|
if (open && !currentShareParam) {
|
||||||
params.set("share", "true");
|
params.set("share", "true");
|
||||||
} else {
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
|
} else if (!open && currentShareParam) {
|
||||||
params.delete("share");
|
params.delete("share");
|
||||||
|
router.push(`${pathname}?${params.toString()}`);
|
||||||
}
|
}
|
||||||
router.push(`${pathname}?${params.toString()}`);
|
|
||||||
setModalState((prev) => ({ ...prev, start: open }));
|
setModalState((prev) => ({ ...prev, start: open }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,13 +109,45 @@ export const SurveyAnalysisCTA = ({
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPreviewUrl = () => {
|
const getPreviewUrl = async () => {
|
||||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||||
return `${surveyUrl}${separator}preview=true`;
|
|
||||||
|
if (survey.singleUse?.enabled) {
|
||||||
|
const newId = await refreshSingleUseId();
|
||||||
|
if (newId) {
|
||||||
|
surveyUrl.searchParams.set("suId", newId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
surveyUrl.searchParams.set("preview", "true");
|
||||||
|
return surveyUrl.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
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 = [
|
const iconActions = [
|
||||||
{
|
{
|
||||||
icon: BellRing,
|
icon: BellRing,
|
||||||
@@ -115,9 +158,18 @@ export const SurveyAnalysisCTA = ({
|
|||||||
{
|
{
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
tooltip: t("common.preview"),
|
tooltip: t("common.preview"),
|
||||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
onClick: async () => {
|
||||||
|
const previewUrl = await getPreviewUrl();
|
||||||
|
window.open(previewUrl, "_blank");
|
||||||
|
},
|
||||||
isVisible: survey.type === "link",
|
isVisible: survey.type === "link",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: ListRestart,
|
||||||
|
tooltip: t("environments.surveys.summary.reset_survey"),
|
||||||
|
onClick: () => setIsResetModalOpen(true),
|
||||||
|
isVisible: !isReadOnly && (responseCount > 0 || displayCount > 0),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: SquarePenIcon,
|
icon: SquarePenIcon,
|
||||||
tooltip: t("common.edit"),
|
tooltip: t("common.edit"),
|
||||||
@@ -132,22 +184,12 @@ export const SurveyAnalysisCTA = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
<div className="hidden justify-end gap-x-1.5 sm:flex">
|
||||||
{survey.resultShareKey && (
|
|
||||||
<Badge
|
|
||||||
type="warning"
|
|
||||||
size="normal"
|
|
||||||
className="rounded-lg"
|
|
||||||
text={t("environments.surveys.summary.results_are_public")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
{!isReadOnly && (widgetSetupCompleted || survey.type === "link") && survey.status !== "draft" && (
|
||||||
<SurveyStatusDropdown environment={environment} survey={survey} />
|
<SurveyStatusDropdown environment={environment} survey={survey} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IconBar actions={iconActions} />
|
<IconBar actions={iconActions} />
|
||||||
<Button
|
<Button
|
||||||
className="h-10"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setModalState((prev) => ({ ...prev, share: true }));
|
setModalState((prev) => ({ ...prev, share: true }));
|
||||||
}}>
|
}}>
|
||||||
@@ -170,6 +212,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
segments={segments}
|
segments={segments}
|
||||||
isContactsEnabled={isContactsEnabled}
|
isContactsEnabled={isContactsEnabled}
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SuccessMessage environment={environment} survey={survey} />
|
<SuccessMessage environment={environment} survey={survey} />
|
||||||
@@ -187,6 +230,17 @@ export const SurveyAnalysisCTA = ({
|
|||||||
secondaryButtonText={t("common.edit")}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+424
-241
@@ -1,288 +1,471 @@
|
|||||||
import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { LucideIcon } from "lucide-react";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import { TSegment } from "@formbricks/types/segment";
|
||||||
TSurvey,
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
TSurveyQuestion,
|
|
||||||
TSurveyQuestionTypeEnum,
|
|
||||||
TSurveySingleUse,
|
|
||||||
} from "@formbricks/types/surveys/types";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { ShareSurveyModal } from "./share-survey-modal";
|
||||||
|
|
||||||
// Mock data
|
// Mock getPublicDomain - must be first to prevent server-side env access
|
||||||
const mockSurveyWeb = {
|
vi.mock("@/lib/getPublicUrl", () => ({
|
||||||
id: "survey1",
|
getPublicDomain: vi.fn().mockReturnValue("https://example.com"),
|
||||||
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 = {
|
// Mock env to prevent server-side env access
|
||||||
...mockSurveyWeb,
|
vi.mock("@/lib/env", () => ({
|
||||||
id: "survey2",
|
env: {
|
||||||
name: "Link Survey",
|
IS_FORMBRICKS_CLOUD: "0",
|
||||||
type: "link",
|
NODE_ENV: "test",
|
||||||
singleUse: { enabled: false, isEncrypted: false } as TSurveySingleUse,
|
E2E_TESTING: "0",
|
||||||
} as unknown as TSurvey;
|
ENCRYPTION_KEY: "test-encryption-key-32-characters",
|
||||||
|
WEBAPP_URL: "https://example.com",
|
||||||
const mockUser = {
|
CRON_SECRET: "test-cron-secret",
|
||||||
id: "user1",
|
PUBLIC_URL: "https://example.com",
|
||||||
name: "Test User",
|
VERCEL_URL: "",
|
||||||
email: "test@example.com",
|
},
|
||||||
role: "project_manager",
|
}));
|
||||||
objective: "other",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
locale: "en-US",
|
|
||||||
} as unknown as TUser;
|
|
||||||
|
|
||||||
|
// Mock the useTranslate hook
|
||||||
vi.mock("@tolgee/react", () => ({
|
vi.mock("@tolgee/react", () => ({
|
||||||
useTranslate: () => ({
|
useTranslate: () => ({
|
||||||
t: (str: string) => str,
|
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;
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/analysis/components/ShareSurveyLink", () => ({
|
// Mock analysis utils
|
||||||
ShareSurveyLink: vi.fn(() => <div>ShareSurveyLinkMock</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/badge", () => ({
|
|
||||||
Badge: vi.fn(({ text }) => <span data-testid="badge-mock">{text}</span>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockShareViewComponent = vi.fn();
|
|
||||||
vi.mock("./shareEmbedModal/share-view", () => ({
|
|
||||||
ShareView: (props: any) => mockShareViewComponent(props),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock getSurveyUrl to return a predictable URL
|
|
||||||
vi.mock("@/modules/analysis/utils", () => ({
|
vi.mock("@/modules/analysis/utils", () => ({
|
||||||
getSurveyUrl: vi.fn().mockResolvedValue("https://public-domain.com/s/survey1"),
|
getSurveyUrl: vi.fn().mockResolvedValue("https://example.com/s/test-survey-id"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let capturedDialogOnOpenChange: ((open: boolean) => void) | undefined;
|
// Mock logger
|
||||||
vi.mock("@/modules/ui/components/dialog", async () => {
|
vi.mock("@formbricks/logger", () => ({
|
||||||
const actual = await vi.importActual<typeof import("@/modules/ui/components/dialog")>(
|
logger: {
|
||||||
"@/modules/ui/components/dialog"
|
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 {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
Dialog: (props: React.ComponentProps<typeof actual.Dialog>) => {
|
Code2Icon: () => <svg data-testid="code2-icon" />,
|
||||||
capturedDialogOnOpenChange = props.onOpenChange;
|
LinkIcon: () => <svg data-testid="link-icon" />,
|
||||||
return <actual.Dialog {...props} />;
|
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" />,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ShareEmbedSurvey", () => {
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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: {},
|
||||||
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
|
||||||
capturedDialogOnOpenChange = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockSetOpen = vi.fn();
|
test("renders dialog when open is true", () => {
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
survey: mockSurveyWeb,
|
|
||||||
publicDomain: "https://public-domain.com",
|
|
||||||
open: true,
|
|
||||||
modalView: "start" as "start" | "share",
|
|
||||||
setOpen: mockSetOpen,
|
|
||||||
user: mockUser,
|
|
||||||
segments: [],
|
|
||||||
isContactsEnabled: true,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockShareViewComponent.mockImplementation(
|
|
||||||
({ tabs, activeId, survey, email, surveyUrl, publicDomain, locale }) => (
|
|
||||||
<div>
|
|
||||||
<div data-testid="shareview-tabs">{JSON.stringify(tabs)}</div>
|
|
||||||
<div data-testid="shareview-activeid">{activeId}</div>
|
|
||||||
<div data-testid="shareview-survey-id">{survey.id}</div>
|
|
||||||
<div data-testid="shareview-email">{email}</div>
|
|
||||||
<div data-testid="shareview-surveyUrl">{surveyUrl}</div>
|
|
||||||
<div data-testid="shareview-publicDomain">{publicDomain}</div>
|
|
||||||
<div data-testid="shareview-locale">{locale}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders initial 'start' view correctly when open and modalView is 'start' for link survey", () => {
|
|
||||||
render(<ShareSurveyModal {...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.share_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.use_personal_links")).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(<ShareSurveyModal {...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.share_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.use_personal_links")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("badge-mock")).toHaveTextContent("common.new");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("switches to 'embed' view when 'Embed survey' button is clicked", async () => {
|
|
||||||
render(<ShareSurveyModal {...defaultProps} />);
|
render(<ShareSurveyModal {...defaultProps} />);
|
||||||
const embedButton = screen.getByText("environments.surveys.summary.share_survey");
|
|
||||||
await userEvent.click(embedButton);
|
expect(screen.getByTestId("dialog")).toHaveAttribute("data-open", "true");
|
||||||
expect(mockShareViewComponent).toHaveBeenCalled();
|
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handleOpenChange (when Dialog calls its onOpenChange prop)", () => {
|
test("renders success view when modalView is start", () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} open={true} survey={mockSurveyWeb} />);
|
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||||
expect(capturedDialogOnOpenChange).toBeDefined();
|
|
||||||
|
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"));
|
||||||
|
|
||||||
// Simulate Dialog closing
|
|
||||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(false);
|
|
||||||
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
expect(mockSetOpen).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
// Simulate Dialog opening
|
|
||||||
mockSetOpen.mockClear();
|
|
||||||
if (capturedDialogOnOpenChange) capturedDialogOnOpenChange(true);
|
|
||||||
expect(mockSetOpen).toHaveBeenCalledWith(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("correctly configures for 'link' survey type in embed view", () => {
|
test("fetches survey URL on mount", async () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
|
const { getSurveyUrl } = await import("@/modules/analysis/utils");
|
||||||
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
|
||||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
render(<ShareSurveyModal {...defaultProps} />);
|
||||||
activeId: string;
|
|
||||||
};
|
await waitFor(() => {
|
||||||
expect(embedViewProps.tabs.length).toBe(4);
|
expect(getSurveyUrl).toHaveBeenCalledWith(mockSurvey, "https://example.com", "default");
|
||||||
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", () => {
|
test("handles getSurveyUrl failure gracefully", async () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />);
|
const { getSurveyUrl } = await import("@/modules/analysis/utils");
|
||||||
const embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
vi.mocked(getSurveyUrl).mockRejectedValue(new Error("Failed to fetch"));
|
||||||
tabs: { id: string; label: string; icon: LucideIcon }[];
|
|
||||||
activeId: string;
|
// Render and verify it doesn't crash, even if nothing renders due to the error
|
||||||
};
|
expect(() => {
|
||||||
expect(embedViewProps.tabs.length).toBe(1);
|
render(<ShareSurveyModal {...defaultProps} />);
|
||||||
expect(embedViewProps.tabs[0].id).toBe("app");
|
}).not.toThrow();
|
||||||
expect(embedViewProps.activeId).toBe("app");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("useEffect does not change activeId if survey.type changes from web to link (while in embed view)", () => {
|
test("renders ShareView with correct active tab", () => {
|
||||||
const { rerender } = render(
|
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||||
<ShareSurveyModal {...defaultProps} survey={mockSurveyWeb} modalView="share" />
|
|
||||||
);
|
|
||||||
expect(vi.mocked(mockShareViewComponent).mock.calls[0][0].activeId).toBe("app");
|
|
||||||
|
|
||||||
rerender(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
|
const shareViewData = screen.getByTestId("share-view-data");
|
||||||
expect(vi.mocked(mockShareViewComponent).mock.calls[1][0].activeId).toBe("app"); // Current behavior
|
expect(shareViewData).toHaveTextContent("Active Tab: anon-links");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("initial showView is set by modalView prop when open is true", () => {
|
test("passes correct props to SuccessView", () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
|
render(<ShareSurveyModal {...defaultProps} modalView="start" />);
|
||||||
expect(mockShareViewComponent).toHaveBeenCalled();
|
|
||||||
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument();
|
|
||||||
cleanup();
|
|
||||||
|
|
||||||
render(<ShareSurveyModal {...defaultProps} open={true} modalView="start" />);
|
const successViewData = screen.getByTestId("success-view-data");
|
||||||
// Start view shows the share survey button
|
expect(successViewData).toHaveTextContent("Survey: test-survey-id");
|
||||||
expect(screen.getByText("environments.surveys.summary.share_survey")).toBeInTheDocument();
|
expect(successViewData).toHaveTextContent("Domain: https://example.com");
|
||||||
|
expect(successViewData).toHaveTextContent("User: test-user-id");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("useEffect sets showView to 'start' when open becomes false", () => {
|
test("resets to start view when modal is closed and reopened", async () => {
|
||||||
const { rerender } = render(<ShareSurveyModal {...defaultProps} open={true} modalView="share" />);
|
const user = userEvent.setup();
|
||||||
expect(screen.getByTestId("shareview-tabs")).toBeInTheDocument(); // Starts in embed
|
const { rerender } = render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||||
|
|
||||||
rerender(<ShareSurveyModal {...defaultProps} open={false} modalView="share" />);
|
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||||
// Dialog mock returns null when open is false, so EmbedViewMockContent is not found
|
|
||||||
expect(screen.queryByTestId("shareview-tabs")).not.toBeInTheDocument();
|
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={false} />);
|
||||||
|
rerender(<ShareSurveyModal {...defaultProps} modalView="share" open={true} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("share-view")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders correct label for link tab based on singleUse survey property", () => {
|
test("sets correct active tab for link survey", () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLink} modalView="share" />);
|
render(<ShareSurveyModal {...defaultProps} modalView="share" />);
|
||||||
let embedViewProps = vi.mocked(mockShareViewComponent).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(mockShareViewComponent).mockClear();
|
|
||||||
|
|
||||||
const mockSurveyLinkSingleUse: TSurvey = {
|
expect(screen.getByTestId("share-view")).toHaveAttribute("data-active-id", "anon-links");
|
||||||
...mockSurveyLink,
|
});
|
||||||
singleUse: { enabled: true, isEncrypted: true },
|
|
||||||
};
|
test("renders tab container for app survey in share mode", () => {
|
||||||
render(<ShareSurveyModal {...defaultProps} survey={mockSurveyLinkSingleUse} modalView="share" />);
|
render(<ShareSurveyModal {...defaultProps} survey={mockAppSurvey} modalView="share" />);
|
||||||
embedViewProps = vi.mocked(mockShareViewComponent).mock.calls[0][0] as {
|
|
||||||
tabs: { id: string; label: string }[];
|
expect(screen.getByTestId("tab-container")).toBeInTheDocument();
|
||||||
};
|
expect(screen.getByTestId("app-tab")).toBeInTheDocument();
|
||||||
linkTab = embedViewProps.tabs.find((tab) => tab.id === "link");
|
expect(screen.queryByTestId("share-view")).not.toBeInTheDocument();
|
||||||
expect(linkTab?.label).toBe("environments.surveys.summary.single_use_links");
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+147
-76
@@ -1,11 +1,21 @@
|
|||||||
"use client";
|
"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 { getSurveyUrl } from "@/modules/analysis/utils";
|
||||||
import { Dialog, DialogContent } from "@/modules/ui/components/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||||
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { Code2Icon, LinkIcon, MailIcon, SmartphoneIcon, UserIcon } from "lucide-react";
|
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TSegment } from "@formbricks/types/segment";
|
import { TSegment } from "@formbricks/types/segment";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
@@ -14,14 +24,6 @@ import { SuccessView } from "./shareEmbedModal/success-view";
|
|||||||
|
|
||||||
type ModalView = "start" | "share";
|
type ModalView = "start" | "share";
|
||||||
|
|
||||||
enum ShareViewType {
|
|
||||||
LINK = "link",
|
|
||||||
PERSONAL_LINKS = "personal-links",
|
|
||||||
EMAIL = "email",
|
|
||||||
WEBPAGE = "webpage",
|
|
||||||
APP = "app",
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ShareSurveyModalProps {
|
interface ShareSurveyModalProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
@@ -32,6 +34,7 @@ interface ShareSurveyModalProps {
|
|||||||
segments: TSegment[];
|
segments: TSegment[];
|
||||||
isContactsEnabled: boolean;
|
isContactsEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ShareSurveyModal = ({
|
export const ShareSurveyModal = ({
|
||||||
@@ -44,62 +47,118 @@ export const ShareSurveyModal = ({
|
|||||||
segments,
|
segments,
|
||||||
isContactsEnabled,
|
isContactsEnabled,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
|
isReadOnly,
|
||||||
}: ShareSurveyModalProps) => {
|
}: ShareSurveyModalProps) => {
|
||||||
const environmentId = survey.environmentId;
|
const environmentId = survey.environmentId;
|
||||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled ?? false;
|
const [surveyUrl, setSurveyUrl] = useState<string>(getSurveyUrl(survey, publicDomain, "default"));
|
||||||
|
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||||
const { email } = user;
|
const { email } = user;
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const linkTabs: { id: ShareViewType; label: string; icon: React.ElementType }[] = useMemo(
|
const linkTabs: {
|
||||||
|
id: ShareViewType;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
componentType: React.ComponentType<any>;
|
||||||
|
componentProps: any;
|
||||||
|
}[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
id: ShareViewType.LINK,
|
id: ShareViewType.ANON_LINKS,
|
||||||
label: `${isSingleUseLinkSurvey ? t("environments.surveys.summary.single_use_links") : t("environments.surveys.summary.share_the_link")}`,
|
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
icon: LinkIcon,
|
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,
|
||||||
|
isReadOnly,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: ShareViewType.PERSONAL_LINKS,
|
id: ShareViewType.PERSONAL_LINKS,
|
||||||
label: t("environments.surveys.summary.personal_links"),
|
label: t("environments.surveys.share.personal_links.nav_title"),
|
||||||
icon: UserIcon,
|
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,
|
id: ShareViewType.EMAIL,
|
||||||
label: t("environments.surveys.summary.email_embed"),
|
label: t("environments.surveys.share.send_email.nav_title"),
|
||||||
icon: MailIcon,
|
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.WEBPAGE,
|
id: ShareViewType.SOCIAL_MEDIA,
|
||||||
label: t("environments.surveys.summary.embed_on_website"),
|
label: t("environments.surveys.share.social_media.title"),
|
||||||
icon: Code2Icon,
|
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, isSingleUseLinkSurvey]
|
[
|
||||||
|
t,
|
||||||
|
survey,
|
||||||
|
publicDomain,
|
||||||
|
setSurveyUrl,
|
||||||
|
user.locale,
|
||||||
|
surveyUrl,
|
||||||
|
environmentId,
|
||||||
|
segments,
|
||||||
|
isContactsEnabled,
|
||||||
|
isFormbricksCloud,
|
||||||
|
email,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const appTabs = [
|
const [activeId, setActiveId] = useState(
|
||||||
{
|
survey.type === "link" ? ShareViewType.ANON_LINKS : ShareViewType.APP
|
||||||
id: ShareViewType.APP,
|
);
|
||||||
label: t("environments.surveys.summary.embed_in_app"),
|
|
||||||
icon: SmartphoneIcon,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const [activeId, setActiveId] = useState(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
|
|
||||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
|
||||||
const [surveyUrl, setSurveyUrl] = useState("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSurveyUrl = async () => {
|
|
||||||
try {
|
|
||||||
const url = await getSurveyUrl(survey, publicDomain, "default");
|
|
||||||
setSurveyUrl(url);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to fetch survey URL:", error);
|
|
||||||
// Fallback to a default URL if fetching fails
|
|
||||||
setSurveyUrl(`${publicDomain}/s/${survey.id}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchSurveyUrl();
|
|
||||||
}, [survey, publicDomain]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -108,10 +167,10 @@ export const ShareSurveyModal = ({
|
|||||||
}, [open, modalView]);
|
}, [open, modalView]);
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
setActiveId(survey.type === "link" ? ShareViewType.LINK : ShareViewType.APP);
|
|
||||||
setOpen(open);
|
setOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
setShowView("start");
|
setShowView("start");
|
||||||
|
setActiveId(ShareViewType.ANON_LINKS);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,37 +183,49 @@ export const ShareSurveyModal = ({
|
|||||||
setActiveId(tabId);
|
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}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
|
<VisuallyHidden asChild>
|
||||||
{showView === "start" ? (
|
<DialogTitle />
|
||||||
<SuccessView
|
</VisuallyHidden>
|
||||||
survey={survey}
|
<DialogContent
|
||||||
surveyUrl={surveyUrl}
|
className="w-full bg-white p-0 lg:h-[700px]"
|
||||||
publicDomain={publicDomain}
|
width={survey.type === "link" ? "wide" : "default"}
|
||||||
setSurveyUrl={setSurveyUrl}
|
aria-describedby={undefined}
|
||||||
user={user}
|
unconstrained>
|
||||||
tabs={linkTabs}
|
{renderContent()}
|
||||||
handleViewChange={handleViewChange}
|
|
||||||
handleEmbedViewWithTab={handleEmbedViewWithTab}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ShareView
|
|
||||||
tabs={survey.type === "link" ? linkTabs : appTabs}
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
-63
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-27
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-152
@@ -1,152 +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 { OptionsSwitch } from "@/modules/ui/components/options-switch";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PreviewTab = ({
|
|
||||||
emailHtmlPreview,
|
|
||||||
email,
|
|
||||||
surveyId,
|
|
||||||
}: {
|
|
||||||
emailHtmlPreview: string;
|
|
||||||
email: string;
|
|
||||||
surveyId: string;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
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="space-y-4">
|
|
||||||
<div className="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">
|
|
||||||
{emailHtmlPreview ? (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: emailHtmlPreview }}></div>
|
|
||||||
) : (
|
|
||||||
<LoadingSpinner />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
title="send preview email"
|
|
||||||
aria-label="send preview email"
|
|
||||||
onClick={() => sendPreviewEmail()}
|
|
||||||
className="shrink-0">
|
|
||||||
{t("environments.surveys.summary.send_preview")}
|
|
||||||
<MailIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const EmbedCodeTab = ({ emailHtml }: { emailHtml: string }) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<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>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const EmailTab = ({ surveyId, email }: EmailTabProps) => {
|
|
||||||
const [emailHtmlPreview, setEmailHtmlPreview] = useState<string>("");
|
|
||||||
const [selectedTab, setSelectedTab] = useState("preview");
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex max-h-full flex-col gap-4">
|
|
||||||
<OptionsSwitch
|
|
||||||
options={[
|
|
||||||
{ value: "preview", label: t("environments.surveys.summary.preview") },
|
|
||||||
{ value: "embed", label: t("environments.surveys.summary.embed_code") },
|
|
||||||
]}
|
|
||||||
currentOption={selectedTab}
|
|
||||||
handleOptionChange={(value) => setSelectedTab(value)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedTab === "preview" ? (
|
|
||||||
<PreviewTab emailHtmlPreview={emailHtmlPreview} email={email} surveyId={surveyId} />
|
|
||||||
) : (
|
|
||||||
<EmbedCodeTab emailHtml={emailHtml} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-155
@@ -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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-72
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-69
@@ -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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-25
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-53
@@ -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"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-25
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-254
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-118
@@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
+433
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
"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;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnonymousLinksTab = ({
|
||||||
|
survey,
|
||||||
|
surveyUrl,
|
||||||
|
publicDomain,
|
||||||
|
setSurveyUrl,
|
||||||
|
locale,
|
||||||
|
isReadOnly,
|
||||||
|
}: 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}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
+383
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user