Compare commits

...

79 Commits

Author SHA1 Message Date
Piyush Gupta
fb17c22fc2 fix: lock file 2025-07-30 10:31:43 +05:30
Piyush Gupta
3d52f7b63b Merge branch 'main' of https://github.com/formbricks/formbricks into fix/form-data-dep 2025-07-30 10:31:30 +05:30
Victor Hugo dos Santos
e29a67b1f6 chore: run checks for PR 6304 (#6309)
Co-authored-by: ompharate <ompharate31@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-29 22:20:01 +00:00
Anshuman Pandey
78f5de2f35 fix: adds swift and kotlin language conventions to formbricks docs (#6316) 2025-07-29 11:09:01 +00:00
pandeymangg
17222a59ef moves the override block to repo root 2025-07-29 16:10:52 +05:30
pandeymangg
1ab856d2f0 fix: adds override rule for form-data package 2025-07-29 15:46:03 +05:30
Dhruwang Jariwala
b1a35d4a69 fix: unformatted db message in client display api (#6176) 2025-07-29 04:08:16 +00:00
Dhruwang Jariwala
2166c44470 feat: ID badge component (#6281) 2025-07-28 09:44:43 +00:00
Anshuman Pandey
080cf741e9 fix: adds api v1/responses docs for limit and skip parameters (#6314) 2025-07-28 07:44:04 +00:00
Anshuman Pandey
8881691509 refactor: refurbish logic editor UI (#6216) 2025-07-25 12:05:49 +00:00
Anshuman Pandey
3045f4437f fix: fixes status schedule updation (#6312) 2025-07-25 10:27:28 +00:00
Dhruwang Jariwala
91ace0e821 fix: scroll to bottom on error (#6301) 2025-07-25 09:11:41 +00:00
Dhruwang Jariwala
6ef281647a fix: unauthorised error on survey list page (#6302) 2025-07-25 06:10:48 +00:00
Dhruwang Jariwala
0aaaaa54ee chore: Don't force Project Onboarding for each project (#6299)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-25 06:10:30 +00:00
Harsh Bhat
b1f78e7bf2 docs: webhook payload (#6307) 2025-07-25 06:00:00 +00:00
Piyush Gupta
7086ce2ca3 fix: removes unused translations (#6308) 2025-07-24 12:55:02 +00:00
Piyush Gupta
8f8b549b1d chore: Remove the public result sharing page. (#6298)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-24 12:06:59 +00:00
Piyush Gupta
28514487e0 chore: sunset weekly summary (#6282) 2025-07-24 12:01:39 +00:00
Piyush Gupta
ee20af54c3 feat: adds an underline option in the rich text editor (#6274)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-23 10:54:05 +00:00
Johannes
d08ec4c9ab docs: Fix domain split docs (#6300)
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-07-23 03:54:53 -07:00
Piyush Gupta
891c83e232 fix: CTA question button URL validation (#6284)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-23 05:48:18 +00:00
Johannes
0b02b00b72 fix: link input length and accessibility error (#6283)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-23 05:01:16 +00:00
Harsh Thakur
a217cdd501 fix: email embed preview spacing issue (#6262) 2025-07-22 17:14:07 +00:00
om pharate
ebe50a4821 fix: render copy link button based on single use survey (#6288) 2025-07-22 16:31:54 +00:00
Johannes
cb68d9defc chore: enable blank issue (#6291) 2025-07-22 10:02:49 -07:00
Victor Hugo dos Santos
c42a706789 fix: Experimental workflow package.json version update (#6287) 2025-07-22 15:19:38 +00:00
Anshuman Pandey
3803111b19 fix: fixes personalized links when single use id is enabled (#6270) 2025-07-22 12:08:45 +00:00
Dhruwang Jariwala
30fdcff737 feat: reset survey (#6267) 2025-07-22 12:04:26 +00:00
Dhruwang Jariwala
e83cfa85a4 fix: github annotations (#6240) 2025-07-22 10:38:34 +00:00
Piyush Gupta
eee9ee8995 chore: Replaces Unkey and Update rate limiting in the management API v2. (#6273) 2025-07-22 09:33:29 +00:00
Dhruwang Jariwala
ed89f12af8 chore: rate limiting for server actions (#6271) 2025-07-22 09:18:12 +00:00
Piyush Gupta
f043314537 fix: required action revert logic (#6269) 2025-07-22 04:10:09 +00:00
Victor Hugo dos Santos
2ce842dd8d chore: updated SAML SSO docs (#6280) 2025-07-22 04:09:11 +00:00
Johannes
43b43839c5 chore: auto-add bug to eng project (#6277) 2025-07-21 08:33:27 -07:00
Piyush Gupta
8b6e3fec37 fix: response filters icons and text (#6266) 2025-07-21 08:48:10 +00:00
Anshuman Pandey
31bcf98779 fix: fixes PIN 4 digit length error (#6265) 2025-07-21 07:30:03 +00:00
Matti Nannt
b35cabcbcc chore(infra): enable cluster public access to mitigate tailscale issues (#6264) 2025-07-19 08:53:31 +02:00
Matti Nannt
4f435f1a1f fix: enable Tailscale subnet routes for EKS access (#6263) 2025-07-18 21:32:01 +02:00
Victor Hugo dos Santos
99c1e434df feat: Deploy to staging on pre-release builds (#6261) 2025-07-18 15:35:00 +00:00
Piyush Gupta
b13699801b fix: survey preview for suid enabled surveys (#6253)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-18 08:54:48 +00:00
Jakob Schott
ceb2e85d96 chore: 742 storybook setup and cursor rule (#6220)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-07-18 08:03:39 +00:00
Anshuman Pandey
c5f8b5ec32 fix: removes suid UI from the survey-editor (#6249)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-18 07:41:05 +00:00
Anshuman Pandey
bdbd57c2fc fix: adds read only survey url (#6252) 2025-07-18 05:14:32 +00:00
Victor Hugo dos Santos
d44aa17814 feat: add sentry sourcemaps to pre-releases (#6242) 2025-07-17 16:11:28 +00:00
Jakob Schott
23d38b4c5b chore: move tab component to storybook (#6214) 2025-07-17 09:26:31 +00:00
Piyush Gupta
58213969e8 feat: remove brevo contact on account deletion (#6231)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 16:00:34 +00:00
Victor Hugo dos Santos
ef973c8995 chore: merge rate limiter epic branch into main (#6236)
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Aditya <162564995+Naidu-4444@users.noreply.github.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Suraj <surajsuthar0067@gmail.com>
Co-authored-by: Kshitij Sharma <63995641+kshitij-codes@users.noreply.github.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
2025-07-16 12:28:59 +00:00
dependabot[bot]
bea02ba3b5 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6161)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-07-16 10:42:54 +00:00
Piyush Jain
1c1e2ee09c chore: add timeout settings for production LB (#5884) 2025-07-16 09:08:11 +00:00
Piyush Gupta
2bf7fe6c54 docs: adds email address validation note (#6239) 2025-07-16 01:55:21 -07:00
Saurav Jain
9639402c39 fix: allow read and write API key permissions for /v1/management/me (#6178)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-16 07:52:10 +00:00
Victor Hugo dos Santos
53213b41ee feat: New share modal - "In App" tab (#6225)
Co-authored-by: Jakob Schott <154420406+jakobsitory@users.noreply.github.com>
Co-authored-by: Jakob Schott <jakob@formbricks.com>
2025-07-15 17:53:47 +00:00
Dhruwang Jariwala
b8b5eead7a fix: close survey on response limit setting behaviour (#6203)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-07-15 16:36:03 +00:00
Jakob Schott
a0044ce376 chore: reduced the breakpoint (#6232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-15 13:49:26 +00:00
Piyush Gupta
b3a1f24683 fix: emails font size (#6228) 2025-07-15 13:37:13 +00:00
Dhruwang Jariwala
f06d48698a feat: social media tab (#6219)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-15 13:28:32 +00:00
Anshuman Pandey
acd508ba19 feat: sharing modal anonymous links (#6224)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-15 08:03:10 +00:00
Piyush Gupta
e5591686b4 fix: source tracking in link surveys (#6209) 2025-07-14 09:23:22 -07:00
Dhruwang Jariwala
7be7466eee feat: qr code tab (#6212)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-14 10:53:52 +00:00
Victor Hugo dos Santos
8af6c15998 feat: new share modal website embed and pop out (#6217) 2025-07-11 12:45:42 +00:00
Piyush Gupta
17d60eb1e7 feat: revamp sharing modal shell (#6190)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-11 04:17:43 +00:00
Johannes
d6ecafbc23 docs: add hidden fields for SDK note (#6215) 2025-07-10 07:35:09 -07:00
Dhruwang Jariwala
599e847686 chore: removed integrity hash chain from audit logging (#6202) 2025-07-10 10:43:57 +00:00
Victor Hugo dos Santos
4e52556f7e feat: add single contact using the API V2 (#6168) 2025-07-10 10:34:18 +00:00
Kshitij Sharma
492a59e7de fix: show multi-choice question first in styling preview (#6150)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 01:41:02 -07:00
Jakob Schott
e0be53805e fix: Spelling mistake for Nodemailer in docs (#5988) 2025-07-10 00:29:50 -07:00
Johannes
5c2860d1a4 docs: Personal Link docs (#6034)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-10 00:13:29 -07:00
Piyush Gupta
18ba5bbd8a fix: types in audit log wrapper (#6200) 2025-07-10 03:55:28 +00:00
Johannes
572b613034 docs: update prefilling docs (#6062) 2025-07-09 08:52:53 -07:00
Abhi-Bohora
a9c7140ba6 fix: Edit Recall button flicker when user types into the edit field (#6121)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-07-09 08:51:42 -07:00
Abhishek Sharma
7fa95cd74a fix: recall fallback input to be displayed on top of other contai… (#6124)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-07-09 08:51:27 -07:00
Nathanaël
8c7f36d496 chore: Update docker-compose.yml, fix syntax (#6158) 2025-07-09 17:39:58 +02:00
Jakob Schott
42dcbd3e7e chore: changed date format on license alert to MMM dd, YYYY (#6182) 2025-07-09 14:57:04 +00:00
Piyush Gupta
1c1cd99510 fix: unsaved survey dialog (#6201) 2025-07-09 08:14:32 +00:00
Dhruwang Jariwala
b0a7e212dd fix: suid copy issue on safari (#6174) 2025-07-08 10:50:02 +00:00
Dhruwang Jariwala
0c1f6f3c3a fix: translations (#6186) 2025-07-08 08:52:36 +00:00
Matti Nannt
9399b526b8 fix: run PR checks on every pull requests (#6185) 2025-07-08 11:07:03 +02:00
Dhruwang Jariwala
cd60032bc9 fix: row/column deletion in matrix question (#6184) 2025-07-08 07:12:16 +00:00
Dhruwang Jariwala
a941f994ea fix: removed userId from contact endpoint response (#6175)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-07-08 06:36:56 +00:00
558 changed files with 28965 additions and 13463 deletions

View File

@@ -18,7 +18,6 @@ apps/web/
│ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components
├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
### Dynamic Routes
- `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern

View File

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

View File

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

View File

@@ -291,11 +291,6 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,16 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/**
* 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.
*/
const getAbsolutePath = (value: string) => {
function getAbsolutePath(value: string): any {
return dirname(require.resolve(join(value, "package.json")));
};
}
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {

View File

@@ -9,8 +9,12 @@ import {
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/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 { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
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", () => ({
getOrganizationProjectsLimit: vi.fn(),
getRoleManagementPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
@@ -71,7 +79,13 @@ vi.mock("@/lib/constants", () => ({
// Mock components
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", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
@@ -104,7 +118,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
} as unknown as TUser;
const mockOrganization = {
@@ -156,6 +170,17 @@ const mockProjectPermission = {
role: "admin",
} as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = {
user: {
id: "user-1",
@@ -176,6 +201,8 @@ describe("EnvironmentLayout", () => {
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -288,6 +315,110 @@ describe("EnvironmentLayout", () => {
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 () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();

View File

@@ -13,7 +13,10 @@ import {
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
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 { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-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"));
}
const [projects, environments] = await Promise.all([
const [projects, environments, canDoRoleManagement] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getRoleManagementPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
@@ -101,6 +105,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
/>
<div className="flex h-full">
@@ -116,6 +121,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
canDoRoleManagement={canDoRoleManagement}
/>
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
<TopControlBar

View File

@@ -1,4 +1,5 @@
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 userEvent from "@testing-library/user-event";
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,
}));
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}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
</div>
),
}));
@@ -106,7 +117,7 @@ const mockUser = {
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
@@ -146,6 +157,7 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
canDoRoleManagement: true,
};
describe("MainNavigation", () => {
@@ -334,4 +346,23 @@ describe("MainNavigation", () => {
});
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");
});
});

View File

@@ -66,6 +66,7 @@ interface NavigationProps {
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
canDoRoleManagement: boolean;
}
export const MainNavigation = ({
@@ -80,6 +81,7 @@ export const MainNavigation = ({
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
canDoRoleManagement,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
@@ -323,6 +325,7 @@ export const MainNavigation = ({
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
canDoRoleManagement={canDoRoleManagement}
/>
)}

View File

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

View File

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

View File

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

View File

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

View File

@@ -220,7 +220,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -258,7 +257,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -236,7 +236,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -272,7 +271,6 @@ const surveys: TSurvey[] = [
languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

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

View File

@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,

View File

@@ -226,7 +226,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
{
@@ -264,7 +263,6 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] },
pin: null,
resultShareKey: null,
displayLimit: null,
} as unknown as TSurvey,
];

View File

@@ -114,7 +114,6 @@ const mockSurveys: TSurvey[] = [
languages: [],
styling: null,
segment: null,
resultShareKey: null,
displayPercentage: null,
closeOnDate: null,
runOnDate: null,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,6 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "project_manager",

View File

@@ -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
});
});

View File

@@ -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>
))}
</>
);
};

View File

@@ -29,7 +29,6 @@ const organizationId = "org1";
const baseNotificationSettings: TUserNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
@@ -68,19 +67,6 @@ describe("NotificationSwitch", () => {
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)", () => {
const settings = { ...baseNotificationSettings, unsubscribedOrganizationIds: [] };
renderSwitch({
@@ -268,31 +254,6 @@ describe("NotificationSwitch", () => {
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 () => {
const mockErrorResponse = { serverError: "Permission denied" };
vi.mocked(updateNotificationSettingsAction).mockResolvedValueOnce(mockErrorResponse);

View File

@@ -12,7 +12,7 @@ import { updateNotificationSettingsAction } from "../actions";
interface NotificationSwitchProps {
surveyOrProjectOrOrganizationId: string;
notificationSettings: TUserNotificationSettings;
notificationType: "alert" | "weeklySummary" | "unsubscribedOrganizationIds";
notificationType: "alert" | "unsubscribedOrganizationIds";
autoDisableNotificationType?: string;
autoDisableNotificationElementId?: string;
}

View File

@@ -34,17 +34,5 @@ describe("Loading Notifications Settings", () => {
.getByText("environments.settings.notifications.email_alerts_surveys")
.closest("div[class*='rounded-xl']"); // Find parent card
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();
});
});

View File

@@ -14,11 +14,6 @@ const Loading = () => {
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" }],
},
{
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 (

View File

@@ -5,7 +5,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TUser } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import Page from "./page";
import { Membership } from "./types";
@@ -58,9 +57,7 @@ vi.mock("@formbricks/database", () => ({
vi.mock("./components/EditAlerts", () => ({
EditAlerts: vi.fn(() => <div>EditAlertsComponent</div>),
}));
vi.mock("./components/EditWeeklySummary", () => ({
EditWeeklySummary: vi.fn(() => <div>EditWeeklySummaryComponent</div>),
}));
vi.mock("./components/IntegrationsTip", () => ({
IntegrationsTip: () => <div>IntegrationsTipComponent</div>,
}));
@@ -71,7 +68,6 @@ const mockUser: Partial<TUser> = {
email: "test@example.com",
notificationSettings: {
alert: { "survey-old": true },
weeklySummary: { "project-old": true },
unsubscribedOrganizationIds: ["org-unsubscribed"],
},
};
@@ -137,13 +133,6 @@ describe("NotificationsPage", () => {
).toBeInTheDocument();
expect(screen.getByText("EditAlertsComponent")).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
// after `setCompleteNotificationSettings` processes it.
@@ -157,16 +146,12 @@ describe("NotificationsPage", () => {
// It iterates memberships, then projects, then environments, then surveys.
// `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.
// `newNotificationSettings.weeklySummary[project.id]` also only adds project IDs from memberships.
const finalExpectedSettings = {
alert: {
"survey-1": false,
"survey-2": false,
},
weeklySummary: {
"project-1": false,
},
unsubscribedOrganizationIds: ["org-unsubscribed"],
};
@@ -175,11 +160,6 @@ describe("NotificationsPage", () => {
expect(editAlertsCall.environmentId).toBe(mockParams.environmentId);
expect(editAlertsCall.autoDisableNotificationType).toBe(mockSearchParams.type);
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 () => {
@@ -207,21 +187,15 @@ describe("NotificationsPage", () => {
render(PageComponent);
expect(screen.getByText("EditAlertsComponent")).toBeInTheDocument();
expect(screen.getByText("EditWeeklySummaryComponent")).toBeInTheDocument();
const expectedEmptySettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
};
const editAlertsCall = vi.mocked(EditAlerts).mock.calls[0][0];
expect(editAlertsCall.user.notificationSettings).toEqual(expectedEmptySettings);
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 () => {
@@ -229,7 +203,6 @@ describe("NotificationsPage", () => {
id: "user-legacy",
notificationSettings: {
"survey-1": { responseFinished: true }, // Legacy alert for survey-1
weeklySummary: { "project-1": true },
unsubscribedOrganizationIds: [],
} as any, // To allow legacy structure
};
@@ -246,9 +219,6 @@ describe("NotificationsPage", () => {
"survey-1": true, // Should be true due to legacy setting
"survey-2": false, // Default for other surveys in membership
},
weeklySummary: {
"project-1": true, // From user's weeklySummary
},
unsubscribedOrganizationIds: [],
};

View File

@@ -9,7 +9,6 @@ import { getServerSession } from "next-auth";
import { prisma } from "@formbricks/database";
import { TUserNotificationSettings } from "@formbricks/types/user";
import { EditAlerts } from "./components/EditAlerts";
import { EditWeeklySummary } from "./components/EditWeeklySummary";
import { IntegrationsTip } from "./components/IntegrationsTip";
import type { Membership } from "./types";
@@ -19,14 +18,10 @@ const setCompleteNotificationSettings = (
): TUserNotificationSettings => {
const newNotificationSettings = {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
};
for (const membership of memberships) {
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
for (const environment of project.environments) {
for (const survey of environment.surveys) {
@@ -183,11 +178,6 @@ const Page = async (props) => {
/>
</SettingsCard>
<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>
);
};

View File

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

View File

@@ -20,7 +20,7 @@ const mockUser = {
email: "test@example.com",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

@@ -15,7 +15,7 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -13,7 +13,7 @@ const mockUser = {
locale: "en-US",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
twoFactorEnabled: false,

View File

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

View File

@@ -76,7 +76,7 @@ const mockUser = {
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",

View File

@@ -129,7 +129,7 @@ const mockUser = {
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, weeklySummary: {} },
notificationSettings: { alert: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;

View File

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

View File

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

View File

@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: "test-redis-url",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: true,
}));
@@ -58,7 +58,6 @@ vi.mock("@/lib/env", () => ({
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext");
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions");
vi.mock("@/app/lib/surveys/surveys");
vi.mock("@/app/share/[sharingKey]/actions");
vi.mock("@/modules/ui/components/secondary-navigation", () => ({
SecondaryNavigation: vi.fn(() => <div data-testid="secondary-navigation" />),
}));
@@ -112,7 +111,6 @@ const mockSurvey = {
surveyClosedMessage: null,
welcomeCard: { enabled: false, headline: { default: "" } } as unknown as TSurvey["welcomeCard"],
segment: null,
resultShareKey: null,
closeOnDate: null,
delay: 0,
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 () => {
mockUsePathname.mockReturnValue(
`/environments/${defaultProps.environmentId}/surveys/${mockSurvey.id}/responses`

View File

@@ -4,7 +4,7 @@ import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
import { useTranslate } from "@tolgee/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";
interface SurveyAnalysisNavigationProps {
@@ -20,11 +20,8 @@ export const SurveyAnalysisNavigation = ({
}: SurveyAnalysisNavigationProps) => {
const pathname = usePathname();
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 = [
{

View File

@@ -50,7 +50,6 @@ const mockSurvey = {
isBackButtonHidden: false,
pin: null,
recontactDays: null,
resultShareKey: null,
runOnDate: null,
showLanguageSwitch: false,
singleUse: null,

View File

@@ -113,7 +113,6 @@ const mockSurvey = {
singleUse: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
welcomeCard: { enabled: false, headline: { default: "Welcome!" } } as unknown as TSurvey["welcomeCard"],
styling: null,
@@ -139,7 +138,7 @@ const mockUser = {
updatedAt: new Date(),
role: "project_manager",
objective: "increase_conversion",
notificationSettings: { alert: {}, weeklySummary: {}, unsubscribedOrganizationIds: [] },
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [

View File

@@ -88,7 +88,6 @@ const mockSurvey = {
surveyClosedMessage: null,
triggers: [],
languages: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;

View File

@@ -28,19 +28,10 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/
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", () => ({
getFormattedFilters: vi.fn(),
}));
vi.mock("@/app/share/[sharingKey]/actions", () => ({
getResponseCountBySurveySharingKeyAction: vi.fn(),
getResponsesBySurveySharingKeyAction: vi.fn(),
}));
vi.mock("@/lib/utils/recall", () => ({
replaceHeadlineRecall: vi.fn((survey) => survey),
}));
@@ -64,12 +55,6 @@ const mockGetResponseCountAction = vi.mocked(
(await import("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"))
.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 mockUseSearchParams = vi.mocked((await import("next/navigation")).useSearchParams);
const mockGetFormattedFilters = vi.mocked((await import("@/app/lib/surveys/surveys")).getFormattedFilters);
@@ -150,8 +135,6 @@ describe("ResponsePage", () => {
mockUseResponseFilter.mockReturnValue(mockResponseFilterState);
mockGetResponsesAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountAction.mockResolvedValue({ data: 20 });
mockGetResponsesBySurveySharingKeyAction.mockResolvedValue({ data: mockResponses });
mockGetResponseCountBySurveySharingKeyAction.mockResolvedValue({ data: 20 });
mockGetFormattedFilters.mockReturnValue({});
});
@@ -159,28 +142,11 @@ describe("ResponsePage", () => {
render(<ResponsePage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("response-data-view")).toBeInTheDocument();
});
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 () => {
const { rerender } = render(<ResponsePage {...defaultProps} />);
await waitFor(() => {

View File

@@ -4,11 +4,9 @@ import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/comp
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 { 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 { getResponsesBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
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 { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
@@ -20,7 +18,6 @@ interface ResponsePageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
user?: TUser;
environmentTags: TTag[];
responsesPerPage: number;
@@ -32,17 +29,12 @@ export const ResponsePage = ({
environment,
survey,
surveyId,
publicDomain,
user,
environmentTags,
responsesPerPage,
locale,
isReadOnly,
}: ResponsePageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const [responses, setResponses] = useState<TResponse[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
@@ -63,30 +55,20 @@ export const ResponsePage = ({
let newResponses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey: sharingKey,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
}
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: (newPage - 1) * responsesPerPage,
filterCriteria: filters,
});
newResponses = getResponsesActionResponse?.data || [];
if (newResponses.length === 0 || newResponses.length < responsesPerPage) {
setHasMore(false);
}
setResponses([...responses, ...newResponses]);
setPage(newPage);
}, [filters, isSharingPage, page, responses, responsesPerPage, sharingKey, surveyId]);
}, [filters, page, responses, responsesPerPage, surveyId]);
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
@@ -114,25 +96,14 @@ export const ResponsePage = ({
setFetchingFirstPage(true);
let responses: TResponse[] = [];
if (isSharingPage) {
const getResponsesActionResponse = await getResponsesBySurveySharingKeyAction({
sharingKey,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
} else {
const getResponsesActionResponse = await getResponsesAction({
surveyId,
limit: responsesPerPage,
offset: 0,
filterCriteria: filters,
});
responses = getResponsesActionResponse?.data || [];
}
responses = getResponsesActionResponse?.data || [];
if (responses.length < responsesPerPage) {
setHasMore(false);
@@ -143,7 +114,7 @@ export const ResponsePage = ({
}
};
fetchInitialResponses();
}, [surveyId, filters, responsesPerPage, sharingKey, isSharingPage]);
}, [surveyId, filters, responsesPerPage]);
useEffect(() => {
setPage(1);
@@ -155,7 +126,6 @@ export const ResponsePage = ({
<>
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && <ResultsShareButton survey={survey} publicDomain={publicDomain} />}
</div>
<ResponseDataView
survey={survey}

View File

@@ -156,7 +156,6 @@ const mockSurvey = {
projectOverwrites: null,
singleUse: null,
pin: null,
resultShareKey: null,
surveyClosedMessage: null,
welcomeCard: {
enabled: false,

View File

@@ -3,6 +3,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import Page from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -73,6 +74,10 @@ vi.mock("@/lib/response/service", () => ({
getResponseCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/display/service", () => ({
getDisplayCountBySurveyId: vi.fn(),
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: vi.fn(),
}));
@@ -115,7 +120,6 @@ vi.mock("next/navigation", () => ({
useParams: () => ({
environmentId: "test-env-id",
surveyId: "test-survey-id",
sharingKey: null,
}),
}));
@@ -178,6 +182,7 @@ describe("ResponsesPage", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getTagsByEnvironmentId).mockResolvedValue(mockTags);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(5);
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
vi.mocked(getPublicDomain).mockReturnValue(mockPublicDomain);
});
@@ -206,6 +211,8 @@ describe("ResponsesPage", () => {
isReadOnly: false,
user: mockUser,
publicDomain: mockPublicDomain,
responseCount: 10,
displayCount: 5,
}),
undefined
);
@@ -224,12 +231,10 @@ describe("ResponsesPage", () => {
environment: mockEnvironment,
survey: mockSurvey,
surveyId: mockSurveyId,
publicDomain: mockPublicDomain,
environmentTags: mockTags,
user: mockUser,
responsesPerPage: 10,
locale: mockLocale,
isReadOnly: false,
}),
undefined
);

View File

@@ -2,6 +2,7 @@ import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentI
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
import { IS_FORMBRICKS_CLOUD, RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
@@ -40,6 +41,7 @@ const Page = async (props) => {
// Get response count for the CTA component
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const displayCount = await getDisplayCountBySurveyId(params.surveyId);
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
@@ -56,6 +58,7 @@ const Page = async (props) => {
user={user}
publicDomain={publicDomain}
responseCount={responseCount}
displayCount={displayCount}
segments={segments}
isContactsEnabled={isContactsEnabled}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
@@ -67,7 +70,6 @@ const Page = async (props) => {
environment={environment}
survey={survey}
surveyId={params.surveyId}
publicDomain={publicDomain}
environmentTags={tags}
user={user}
responsesPerPage={RESPONSES_PER_PAGE}

View File

@@ -14,10 +14,10 @@ import { generatePersonalLinks } from "@/modules/ee/contacts/lib/contacts";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationLogoUrl } from "@/modules/ee/whitelabel/email-customization/lib/organization";
import { sendEmbedSurveyPreviewEmail } from "@/modules/email";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError, UnknownError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./lib/survey";
const ZSendEmbedSurveyPreviewEmailAction = z.object({
surveyId: ZId,
@@ -64,143 +64,60 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
);
});
const ZGenerateResultShareUrlAction = z.object({
const ZResetSurveyAction = z.object({
surveyId: ZId,
organizationId: ZId,
projectId: ZId,
});
export const generateResultShareUrlAction = authenticatedActionClient
.schema(ZGenerateResultShareUrlAction)
.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),
},
],
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZResetSurveyAction>;
}) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId: parsedInput.projectId,
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = null;
const resultShareKey = customAlphabet(
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
20
)();
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
parsedInput.surveyId
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
ctx.auditLoggingCtx.oldObject = survey;
ctx.auditLoggingCtx.newObject = {
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
const newSurvey = await updateSurvey({ ...survey, resultShareKey });
ctx.auditLoggingCtx.newObject = newSurvey;
return resultShareKey;
}
)
);
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 {
success: true,
deletedResponsesCount: deletedResponsesCount,
deletedDisplaysCount: deletedDisplaysCount,
};
}
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({
surveyId: ZId,
@@ -313,3 +230,42 @@ export const generatePersonalLinksAction = authenticatedActionClient
count: csvData.length,
};
});
const ZUpdateSingleUseLinksAction = z.object({
surveyId: ZId,
environmentId: ZId,
isSingleUse: z.boolean(),
isSingleUseEncryption: z.boolean(),
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
const survey = await getSurvey(parsedInput.surveyId);
if (!survey) {
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const updatedSurvey = await updateSurvey({
...survey,
singleUse: { enabled: parsedInput.isSingleUse, isEncrypted: parsedInput.isSingleUseEncryption },
});
return updatedSurvey;
});

View File

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

View File

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

View File

@@ -1,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();
});
});

View File

@@ -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>
);
};

View File

@@ -74,7 +74,6 @@ describe("SuccessMessage", () => {
surveyClosedMessage: null,
hiddenFields: { enabled: false, fieldIds: [] },
variables: [],
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;

View File

@@ -177,7 +177,6 @@ const mockSurvey = {
autoClose: null,
triggers: [],
languages: [],
resultShareKey: null,
singleUse: null,
styling: null,
surveyClosedMessage: null,

View File

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

View File

@@ -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
vi.mock(
"@/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>,
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResultsShareButton", () => ({
ResultsShareButton: () => <div data-testid="results-share-button">Share Results</div>,
}));
// Mock context
vi.mock("@/app/(app)/environments/[environmentId]/components/ResponseFilterContext", () => ({
useResponseFilter: () => ({
@@ -172,7 +131,6 @@ describe("SummaryPage", () => {
webAppUrl: "https://app.example.com",
totalResponseCount: 50,
locale,
isReadOnly: false,
};
test("renders loading state initially", () => {
@@ -191,7 +149,6 @@ describe("SummaryPage", () => {
});
expect(screen.getByTestId("custom-filter")).toBeInTheDocument();
expect(screen.getByTestId("results-share-button")).toBeInTheDocument();
expect(screen.getByTestId("scroll-to-top")).toBeInTheDocument();
expect(screen.getByTestId("summary-list")).toBeInTheDocument();
});
@@ -214,15 +171,4 @@ describe("SummaryPage", () => {
// Drop-offs should now be visible
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();
});
test("doesn't show share button in read-only mode", async () => {
render(<SummaryPage {...defaultProps} isReadOnly={true} />);
// Wait for loading to complete
await waitFor(() => {
expect(screen.getByText("Is Loading: false")).toBeInTheDocument();
});
expect(screen.queryByTestId("results-share-button")).not.toBeInTheDocument();
});
});

View File

@@ -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 { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
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 { getSummaryBySurveySharingKeyAction } from "@/app/share/[sharingKey]/actions";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useParams, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
@@ -36,9 +34,7 @@ interface SummaryPageProps {
environment: TEnvironment;
survey: TSurvey;
surveyId: string;
publicDomain: string;
locale: TUserLocale;
isReadOnly: boolean;
initialSurveySummary?: TSurveySummary;
}
@@ -46,15 +42,9 @@ export const SummaryPage = ({
environment,
survey,
surveyId,
publicDomain,
locale,
isReadOnly,
initialSurveySummary,
}: SummaryPageProps) => {
const params = useParams();
const sharingKey = params.sharingKey as string;
const isSharingPage = !!sharingKey;
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
@@ -87,17 +77,10 @@ export const SummaryPage = ({
const currentFilters = getFormattedFilters(survey, selectedFilter, dateRange);
let updatedSurveySummary;
if (isSharingPage) {
updatedSurveySummary = await getSummaryBySurveySharingKeyAction({
sharingKey,
filterCriteria: currentFilters,
});
} else {
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
}
updatedSurveySummary = await getSurveySummaryAction({
surveyId,
filterCriteria: currentFilters,
});
const surveySummary = updatedSurveySummary?.data ?? defaultSurveySummary;
setSurveySummary(surveySummary);
@@ -109,7 +92,7 @@ export const SummaryPage = ({
};
fetchSummary();
}, [selectedFilter, dateRange, survey, isSharingPage, sharingKey, surveyId, initialSurveySummary]);
}, [selectedFilter, dateRange, survey, surveyId, initialSurveySummary]);
const surveyMemoized = useMemo(() => {
return replaceHeadlineRecall(survey, "default");
@@ -132,9 +115,6 @@ export const SummaryPage = ({
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
{!isReadOnly && !isSharingPage && (
<ResultsShareButton survey={surveyMemoized} publicDomain={publicDomain} />
)}
</div>
<ScrollToTop containerId="mainContent" />
<SummaryList

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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