Compare commits

...

78 Commits

Author SHA1 Message Date
Dhruwang
f4512cc86e synced translations 2025-09-10 15:49:07 +05:30
Dhruwang
0c77da1456 Merge branch 'main' of https://github.com/formbricks/formbricks into feat/6511-chinese-simplified-translations 2025-09-10 15:48:19 +05:30
Dhruwang
2db309dd1f zh-CN -> zh-Hans-CN 2025-09-10 11:44:44 +05:30
Piyush Gupta
feee22b5c3 feat: Quota management(part 1 & part 2) (#6521)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
2025-09-09 13:25:05 +00:00
Dhruwang Jariwala
a5433f6748 feat: improved project and org switch (#6500)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-09 12:58:44 +00:00
Dhruwang Jariwala
557f14bab8 fix: requried questions being skipped (#6506)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-09-09 08:33:41 +00:00
Dhruwang Jariwala
fdba260301 fix: project styling settings issues (#6488) 2025-09-09 08:33:28 +00:00
Yuuenn
f0c4c7000c fix(i18n): correct zh-CN label for Traditional Chinese 2025-09-08 20:36:41 +03:00
Yuuenn
ccd401c0a5 add translation for fix#6512 2025-09-08 19:52:02 +03:00
Yuuenn
e9bf0b7dd9 Merge remote-tracking branch 'upstream/main' into feat/6511-chinese-simplified-translations
Merge upstream/main into feat/6511-chinese-simplified-translations to sync with latest changes
2025-09-08 19:43:17 +03:00
Yuuenn
9e322a4fff fix translation 2025-09-08 19:17:33 +03:00
Yuuenn
cf36b59fd6 fix translation 2025-09-08 18:31:53 +03:00
devin-ai-integration[bot]
764b8ec260 docs: update single use links documentation to reflect sharing modal location (#6513)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-08 12:08:26 +00:00
Dhruwang Jariwala
ac5d1e651e fix: untranslated string (#6512) 2025-09-08 10:27:31 +00:00
Yuuenn
83b6f71867 feat(i18n): add Simplified Chinese (zh-CN) translations 2025-09-07 17:17:02 +03:00
Yuuenn
26203640a4 feat: simplified chinese translations 2025-09-06 21:33:51 +03:00
Johannes
62ffcc8e68 docs: clarified Roles docs + added 2FA (#6507) 2025-09-05 04:03:44 -07:00
Dhruwang Jariwala
326872a86b fix: response data table settings modal breaking (#6501) 2025-09-05 10:41:39 +00:00
Victor Hugo dos Santos
892b55662e chore: conditionally enable Sentry plugin based on authentication token (#6502) 2025-09-05 09:44:46 +00:00
Harsh Bhat
23143c8664 docs: Integrate mintlify docs with Posthog (#6487)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-09-05 07:27:21 +00:00
dependabot[bot]
4c71caf0da chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#6491)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-05 07:50:27 +02:00
Dhruwang Jariwala
173821f846 chore: dropdown menu storybook (#6453) 2025-09-04 05:20:38 +00:00
Matti Nannt
f139830020 fix: sentry source map upload workflow authentication (#6327)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Victor Hugo dos Santos <115753265+victorvhs017@users.noreply.github.com>
2025-09-02 21:57:41 +00:00
Jonathan Reimer
70979a3b5b Add Linux Foundation health score badge to README (#6496) 2025-09-02 18:35:14 +02:00
Matti Nannt
061fa036be chore: add deployment options to ECR image build action (#6498) 2025-09-02 17:54:15 +02:00
Dhruwang Jariwala
b83c0a4a5d chore: update romanian translations (#6495) 2025-09-02 10:58:49 +00:00
Dhruwang Jariwala
1bc0563965 fix: update action class issue (#6484) 2025-09-02 10:44:22 +00:00
Dhruwang Jariwala
3a4e2a9f85 fix: duplicate response and contact deletion calls (#6489) 2025-09-02 05:49:20 +00:00
Dhruwang Jariwala
bd48139a4f chore: tag stories (#6468) 2025-09-01 13:46:10 +00:00
Harsh Bhat
89fe82a0d6 docs: Add docs for Link settings (#6492) 2025-09-01 13:44:06 +00:00
om pharate
65dc1fa771 fix(tooltip): wrap TooltipContent in a Portal for improved rendering (#6458)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-01 11:49:54 +00:00
Dhruwang Jariwala
438990bffc chore: slider component story (#6469) 2025-09-01 10:56:50 +00:00
Dhruwang Jariwala
7f7bc989c6 fix: data table toolbar alignment (#6486) 2025-09-01 10:14:22 +00:00
Victor Hugo dos Santos
baa2b31bc9 fix: conditional logic build groups bug (#6476) 2025-09-01 10:04:31 +00:00
Matti Nannt
77aecf3aad chore: upgrade nextjs to 15.5.0 (#6454)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-09-01 09:51:17 +02:00
Dhruwang Jariwala
7c1110239b fix: mobile preview on large screens (#6478) 2025-08-29 08:51:40 +00:00
Dhruwang Jariwala
eeb337521b fix: email verify survey question preview (#6474) 2025-08-29 05:46:14 +00:00
Dhruwang Jariwala
182f674879 fix: multiple recalls in redirect url (#6467) 2025-08-28 08:38:58 +00:00
Piyush Gupta
73c0da4b75 chore: Updates prisma to the latest version (#6457) 2025-08-28 07:44:01 +00:00
Matti Nannt
f475b2e6d5 chore: remove deprecated scale plan from stripe subscription update (#6472) 2025-08-27 14:38:38 +00:00
Dhruwang Jariwala
e5e8941016 chore: tweaked confirmation modal (#6471)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-08-27 13:11:23 +00:00
Anshuman Pandey
c39c9998f0 fix: surveys package rtl (#6379)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-08-27 05:52:46 +00:00
Piyush Gupta
a8c8e6f83f feat: adds switch component stories (#6462)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-08-26 06:15:03 +00:00
Dhruwang Jariwala
8a5e9f38d7 chore: delete dialog stories (#6452)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-26 05:54:28 +00:00
Dhruwang Jariwala
a0740d20ea chore: improved version comparison (#6413) 2025-08-26 05:54:16 +00:00
Dhruwang Jariwala
71f378a494 fix: select dropdown in project create modal (#6465) 2025-08-26 04:46:23 +00:00
Dhruwang Jariwala
4bececeb56 fix: Japanese translations (#6464) 2025-08-25 12:06:49 +00:00
Satoshi
71c96f48d7 feat: japanese translations (#6461) 2025-08-25 02:27:31 -07:00
Johannes
05d88a3069 docs: add API reference to Personal Links (#6463) 2025-08-24 23:48:36 -07:00
Piyush Gupta
b6a63edc88 feat: adds line break support in open text question textarea (#6456) 2025-08-25 05:57:04 +00:00
Matti Nannt
a3764f0316 chore: increase data migration timeout to 600s (#6455) 2025-08-21 21:46:30 +02:00
Piyush Gupta
ec52bdf3fe feat: adds stories for logo component (#6448) 2025-08-20 14:57:43 +00:00
Victor Hugo dos Santos
2e9ad3ce07 fix: community PR check 6400 (#6427)
Co-authored-by: Alex <alexander.seliakov@gmail.com>
2025-08-20 09:04:48 +00:00
Matti Nannt
654bd232d6 chore(cursor): add monorepo overview rule and refine database rule metadata (#6444) 2025-08-20 07:29:03 +00:00
Piyush Gupta
01984cf8ca chore: upgrades prisma to latest version (#6442) 2025-08-19 14:40:31 +00:00
Anshuman Pandey
3eb18bb120 fix: alert message on invalid file in file upload question (#6431) 2025-08-19 12:42:31 +00:00
Piyush Gupta
59859d0e4f fix: organization access checks (#6441) 2025-08-19 11:23:59 +00:00
Piyush Gupta
c60c8cb7bd feat: adds stories for tooltip component (#6433) 2025-08-19 07:48:46 +00:00
Matti Nannt
9fa7aef253 chore: upgrade node-alpine image to 3.22 (#6437) 2025-08-19 07:45:46 +00:00
Piyush Gupta
a23594428a fix: color picker in product logo (#6434)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-19 07:14:23 +00:00
Piyush Gupta
56e7106d6e fix: open text number question logic evaluation (#6439) 2025-08-19 06:03:49 +00:00
Matti Nannt
318f891540 fix: ECR workflow action failing (#6435) 2025-08-18 16:58:30 +02:00
Piyush Gupta
a59881f9ae feat: adds drag and drop to matrix question fields (#6386) 2025-08-18 14:38:53 +00:00
Matti Nannt
7ab4a45ad6 feat: add ecr workflow (#6414) 2025-08-18 15:17:14 +02:00
Matti Nannt
2990e3805f fix: docker security scan action not uploading results (#6432) 2025-08-18 13:54:58 +02:00
Dhruwang Jariwala
29132ab029 fix: metadata issue (#6422)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
2025-08-15 09:50:15 +00:00
Dhruwang Jariwala
f860d8d25d fix: link preview settings tweaks (#6418) 2025-08-14 15:48:05 +00:00
Anshuman Pandey
3501990a79 fix: syntax issue in docker release action (#6415) 2025-08-14 12:12:42 +00:00
Piyush Gupta
41d60c8a02 chore: custom avatar removal (#6408)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-08-14 10:17:05 +00:00
Anshuman Pandey
a6269f0fd3 fix: disables share tabs when single use is active (#6410) 2025-08-14 08:49:15 +00:00
Dhruwang Jariwala
9c0d0a16a7 fix: hover on survey close button (#6405) 2025-08-14 08:11:15 +00:00
Piyush Gupta
c6241f7e7f fix: Inconsistent icon - Picture select vs. question header image (#6409) 2025-08-13 13:09:23 +00:00
Piotr Gaczkowski
92f1c2b75a fix: make terraform apply work again (#6403)
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-13 12:19:18 +00:00
Dhruwang Jariwala
4d53291c8a fix: checks and rate limiting for email verification survey action (#6406) 2025-08-13 06:42:08 +00:00
Matti Nannt
14b7a69cea fix: permissions in release workflow (#6399) 2025-08-13 08:35:26 +02:00
Piyush Gupta
a9015b008d docs: adds identifier note in saml sso docs (#6402) 2025-08-12 11:18:44 +00:00
Dhruwang Jariwala
d19d624c0c feat: filters for url in metadata (#6387)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-08-12 09:37:12 +00:00
Matti Nannt
3edaab6c2b fix: release workflow environment is not accessible (#6398) 2025-08-12 10:31:05 +02:00
415 changed files with 26985 additions and 6349 deletions

View File

@@ -1,12 +1,8 @@
---
description: >
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
and data patterns. It should be used **only when the agent explicitly requests database schema-level
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
globs: []
alwaysApply: agent-requested
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
alwaysApply: false
---
# Formbricks Database Schema Reference
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
@@ -16,6 +12,7 @@ This rule provides a reference to the Formbricks database structure. For the mos
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy
```
Organization
└── Project
@@ -29,6 +26,7 @@ Organization
## Schema Reference
For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts`
@@ -37,17 +35,22 @@ The schema.prisma file contains all model definitions, relationships, enums, and
## Data Access Patterns
### Multi-tenancy
- All data is scoped by Organization
- Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys
### Soft Deletion
Some models use soft deletion patterns:
- Check `isActive` fields where present
- Use proper filtering in queries
### Cascading Deletes
Configured cascade relationships:
- Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses
@@ -55,6 +58,7 @@ Configured cascade relationships:
## Common Query Patterns
### Survey with Responses
```typescript
// Include response count and latest responses
const survey = await prisma.survey.findUnique({
@@ -62,40 +66,40 @@ const survey = await prisma.survey.findUnique({
include: {
responses: {
take: 10,
orderBy: { createdAt: 'desc' }
orderBy: { createdAt: "desc" },
},
_count: {
select: { responses: true }
}
}
select: { responses: true },
},
},
});
```
### Environment Scoping
```typescript
// Always scope by environment
const surveys = await prisma.survey.findMany({
where: {
environmentId: environmentId,
// Additional filters...
}
},
});
```
### Contact with Attributes
```typescript
const contact = await prisma.contact.findUnique({
where: { id: contactId },
include: {
attributes: {
include: {
attributeKey: true
}
}
}
attributeKey: true,
},
},
},
});
```
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.

View File

@@ -1,23 +1,28 @@
---
description: Guideline for writing end-user facing documentation in the apps/docs folder
globs:
globs:
alwaysApply: false
---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
---
title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link"
---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
- The page should never start with H1 headline, because it's already part of the template.
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
</Note>

View File

@@ -0,0 +1,74 @@
---
alwaysApply: true
---
### Formbricks Monorepo Overview
- **Project**: Formbricks — opensource survey and experience management platform. Repo: [formbricks/formbricks](https://github.com/formbricks/formbricks)
- **Monorepo**: Turborepo + pnpm workspaces. Root configs: [package.json](mdc:package.json), [turbo.json](mdc:turbo.json)
- **Core app**: Next.js app in `apps/web` with Prisma, Auth.js, TailwindCSS, Vitest, Playwright. Enterprise modules live in [apps/web/modules/ee](mdc:apps/web/modules/ee)
- **Datastores**: PostgreSQL + Redis. Local dev via [docker-compose.dev.yml](mdc:docker-compose.dev.yml); Prisma schema at [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
- **Docs & Ops**: Docs in `docs/` (Mintlify), Helm in `helm-chart/`, IaC in `infra/`
### Apps
- **apps/web**: Next.js product application (API, UI, SSO, i18n, emails, uploads, integrations)
- **apps/storybook**: Storybook for UI components; a11y addon + Vite builder
### Packages
- **@formbricks/database** (`packages/database`): Prisma schema, DB scripts, migrations, data layer
- **@formbricks/js-core** (`packages/js-core`): Core runtime for web embed / async loader
- **@formbricks/surveys** (`packages/surveys`): Embeddable survey rendering and helpers
- **@formbricks/logger** (`packages/logger`): Shared logging (pino) + Zod types
- **@formbricks/types** (`packages/types`): Shared types (Zod, Prisma clients)
- **@formbricks/i18n-utils** (`packages/i18n-utils`): i18n helpers and build output
- **@formbricks/eslint-config** (`packages/config-eslint`): Central ESLint config (Next, TS, Vitest, Prettier)
- **@formbricks/config-typescript** (`packages/config-typescript`): Central TS config and types
- **@formbricks/vite-plugins** (`packages/vite-plugins`): Internal Vite plugins
- **packages/android, packages/ios**: Native SDKs (built with platform toolchains)
### Enterpriseready by design
- **Quality & safety**: Strict TypeScript, repowide ESLint + Prettier, lintstaged + Husky, CI checks, typed env validation
- **Securityfirst**: Auth.js, SSO/SAML/OIDC, session controls, rate limiting, Sentry, structured logging
### Accessible by design
- **UI foundations**: Radix UI, TailwindCSS, Storybook with `@storybook/addon-a11y`, keyboard and screenreaderfriendly components
### Root pnpm commands
```bash
pnpm clean:all # Clean turbo cache, node_modules, lockfile, coverage, out
pnpm clean # Clean turbo cache, node_modules, coverage, out
pnpm build # Build all packages/apps (turbo)
pnpm build:dev # Dev-optimized builds (where supported)
pnpm dev # Run all dev servers in parallel
pnpm start # Start built apps/services
pnpm go # Start DB (docker compose) and run long-running dev tasks
pnpm generate # Run generators (e.g., Prisma, API specs)
pnpm lint # Lint all
pnpm format # Prettier write across repo
pnpm test # Unit tests
pnpm test:coverage # Unit tests with coverage
pnpm test:e2e # Playwright tests
pnpm test-e2e:azure # Playwright tests with Azure config
pnpm storybook # Run Storybook
pnpm db:up # Start local Postgres/Redis via docker compose
pnpm db:down # Stop local DB stack
pnpm db:start # Project-level DB setup choreography
pnpm db:push # Prisma db push (accept data loss in package script)
pnpm db:migrate:dev # Apply dev migrations
pnpm db:migrate:deploy # Apply prod migrations
pnpm fb-migrate-dev # Create DB migration (database package) and prisma generate
pnpm tolgee-pull # Pull translation keys for current branch and format
```
### Essentials for every prompt
- **Tech stack**: Next.js, React 19, TypeScript, Prisma, Zod, TailwindCSS, Turborepo, Vitest, Playwright
- **Environments**: See `.env.example`. Many tasks require DB up and env variables set
- **Licensing**: Core under AGPLv3; Enterprise code in `apps/web/modules/ee` (included in Docker, unlocked via Enterprise License Key)
For deeper details, consult perpackage `package.json` and scripts (e.g., [apps/web/package.json](mdc:apps/web/package.json)).

View File

@@ -1,104 +0,0 @@
name: "Upload Sentry Sourcemaps"
description: "Extract sourcemaps from Docker image and upload to Sentry"
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
release_version:
description: "Sentry release version (e.g., v1.2.3)"
required: true
sentry_auth_token:
description: "Sentry authentication token"
required: true
environment:
description: "Sentry environment (e.g., production, staging)"
required: false
default: "staging"
runs:
using: "composite"
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract sourcemaps from Docker image
shell: bash
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}
run: |
set -euo pipefail
# Validate docker image format (basic validation)
if [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+:[a-zA-Z0-9._-]+$ ]] && [[ ! "$DOCKER_IMAGE" =~ ^[a-zA-Z0-9._/-]+@sha256:[A-Fa-f0-9]{64}$ ]]; then
echo "❌ Error: Invalid docker image format. Must be in format 'image:tag' or 'image@sha256:hash'"
echo "Provided: ${DOCKER_IMAGE}"
exit 1
fi
echo "📦 Extracting sourcemaps from Docker image: ${DOCKER_IMAGE}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "$DOCKER_IMAGE")
echo "Container created with ID: ${CONTAINER_ID}"
# Set up cleanup function to ensure container is removed on script exit
cleanup_container() {
# Capture the current exit code to preserve it
local original_exit_code=$?
echo "🧹 Cleaning up Docker container..."
# Remove the container if it exists (ignore errors if already removed)
if [ -n "$CONTAINER_ID" ]; then
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
echo "Container ${CONTAINER_ID} removed"
fi
# Exit with the original exit code to preserve script success/failure status
exit $original_exit_code
}
# Register cleanup function to run on script exit (success or failure)
trap cleanup_container EXIT
# Extract .next directory containing sourcemaps
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
# Verify sourcemaps exist
if [ ! -d "./extracted-next/static/chunks" ]; then
echo "❌ Error: .next/static/chunks directory not found in Docker image"
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
exit 1
fi
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
echo "✅ Found ${sourcemap_count} sourcemap files"
if [ "$sourcemap_count" -eq 0 ]; then
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
exit 1
fi
- name: Create Sentry release and upload sourcemaps
uses: getsentry/action-release@v3
env:
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
SENTRY_ORG: formbricks
SENTRY_PROJECT: formbricks-cloud
with:
environment: ${{ inputs.environment }}
version: ${{ inputs.release_version }}
sourcemaps: "./extracted-next/"
- name: Clean up extracted files
shell: bash
if: always()
run: |
set -euo pipefail
# Clean up extracted files
rm -rf ./extracted-next
echo "🧹 Cleaned up extracted files"

168
.github/workflows/build-and-push-ecr.yml vendored Normal file
View File

@@ -0,0 +1,168 @@
name: Build & Push Docker to ECR
on:
workflow_dispatch:
inputs:
image_tag:
description: "Image tag to push (e.g., v3.16.1, main)"
required: true
default: "v3.16.1"
deploy_production:
description: "Tag image for production deployment"
required: false
default: false
type: boolean
deploy_staging:
description: "Tag image for staging deployment"
required: false
default: false
type: boolean
permissions:
contents: read
id-token: write
env:
ECR_REGION: ${{ vars.ECR_REGION }}
# ECR settings are sourced from repository/environment variables for portability across envs/forks
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
DOCKERFILE: apps/web/Dockerfile
CONTEXT: .
jobs:
build-and-push:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate image tag input
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ Image tag is required (non-empty)."
exit 1
fi
if (( ${#IMAGE_TAG} > 128 )); then
echo "❌ Image tag must be at most 128 characters."
exit 1
fi
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
exit 1
fi
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
echo "❌ Image tag must not start or end with '.' or '-'."
exit 1
fi
- name: Validate required variables
shell: bash
env:
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
ECR_REGION: ${{ env.ECR_REGION }}
run: |
set -euo pipefail
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
exit 1
fi
- name: Update package.json version
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
run: |
set -euo pipefail
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
VERSION="${IMAGE_TAG#v}"
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
echo "Original input: ${IMAGE_TAG}"
echo "Extracted version: ${VERSION}"
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
exit 1
fi
echo "✅ Valid SemVer format detected: ${VERSION}"
echo "Updating package.json version to: ${VERSION}"
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Build tag list
id: tags
shell: bash
env:
IMAGE_TAG: ${{ inputs.image_tag }}
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
run: |
set -euo pipefail
# Start with the base image tag
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
# Add production tag if requested
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
fi
# Add staging tag if requested
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
fi
# Output for debugging
echo "Generated tags:"
echo -e "${TAGS}"
# Set output for next step (escape newlines for GitHub Actions)
{
echo "tags<<EOF"
echo -e "${TAGS}"
echo "EOF"
} >> "${GITHUB_OUTPUT}"
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
with:
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
aws-region: ${{ env.ECR_REGION }}
- name: Log in to Amazon ECR
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
- name: Build and push image (Depot remote builder)
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
with:
project: tw0fqmsx3c
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
context: ${{ env.CONTEXT }}
file: ${{ env.DOCKERFILE }}
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.tags.outputs.tags }}
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -37,7 +37,7 @@ on:
permissions:
id-token: write
contents: write
contents: read
jobs:
helmfile-deploy:

View File

@@ -17,7 +17,34 @@ jobs:
scan:
name: Vulnerability Scan
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout (for SARIF fingerprinting only)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 1
- name: Determine ref and commit for upload
id: gitref
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
else
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
@@ -35,6 +62,9 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() && hashFiles('trivy-results.sarif') != '' }}
if: ${{ always() }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"

View File

@@ -7,12 +7,13 @@ on:
permissions:
contents: read
env:
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
jobs:
docker-build:
name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
@@ -20,6 +21,9 @@ jobs:
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
@@ -29,6 +33,9 @@ jobs:
deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs:
@@ -36,32 +43,6 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ env.ENVIRONMENT }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
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

@@ -29,9 +29,7 @@ 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)
@@ -54,23 +52,27 @@ jobs:
echo "Reference type: $REF_TYPE"
echo "Reference name: $REF_NAME"
# Create unique timestamped version for testing sourcemap resolution
TIMESTAMP=$(date +%s)
if [[ "$REF_TYPE" == "tag" ]]; then
# If running from a tag, use the tag name
# If running from a tag, use the tag name + timestamp
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"
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
VERSION="${BASE_VERSION}-${TIMESTAMP}"
echo "Using SemVer tag with timestamp: $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"
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
echo "Using tag as prerelease with timestamp: $VERSION"
fi
else
# Running from branch, use branch name as prerelease
# Running from branch, use branch name as prerelease + timestamp
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"
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
echo "Using branch as prerelease with timestamp: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -82,15 +84,6 @@ jobs:
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
@@ -117,6 +110,9 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=${{ env.VERSION }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -135,21 +131,9 @@ jobs:
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
- 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
@@ -164,31 +148,4 @@ jobs:
DIGEST: ${{ steps.build-and-push.outputs.digest }}
# 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: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # 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
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"

View File

@@ -109,7 +109,7 @@ jobs:
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' }}
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -128,6 +128,7 @@ jobs:
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker

View File

@@ -1,48 +0,0 @@
name: Upload Sentry Sourcemaps (Manual)
on:
workflow_dispatch:
inputs:
docker_image:
description: "Docker image to extract sourcemaps from"
required: true
type: string
release_version:
description: "Release version (e.g., v1.2.3)"
required: true
type: string
tag_version:
description: "Docker image tag (leave empty to use release_version)"
required: false
type: string
permissions:
contents: read
jobs:
upload-sourcemaps:
name: Upload Sourcemaps to Sentry
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> $GITHUB_ENV
env:
DOCKER_IMAGE: ${{ inputs.docker_image }}:${{ inputs.tag_version != '' && inputs.tag_version || inputs.release_version }}
- name: Upload Sourcemaps to Sentry
uses: ./.github/actions/upload-sentry-sourcemaps
with:
docker_image: ${{ env.DOCKER_IMAGE }}
release_version: ${{ inputs.release_version }}
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}

View File

@@ -35,6 +35,14 @@
{
"language": "ro-RO",
"path": "./apps/web/locales/ro-RO.json"
},
{
"language": "ja-JP",
"path": "./apps/web/locales/ja-JP.json"
},
{
"language": "zh-Hans-CN",
"path": "./apps/web/locales/zh-Hans-CN.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -21,6 +21,7 @@ The Open Source Qualtrics Alternative
<p align="center">
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
<a href="https://insights.linuxfoundation.org/project/formbricks"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=formbricks"></a>
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>

View File

@@ -1,6 +1,8 @@
import type { Preview } from "@storybook/react-vite";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
// Import translation data for Storybook
import enUSTranslations from "../../web/locales/en-US.json";
import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
@@ -12,7 +14,16 @@ const withTolgee = (Story: any) => {
return React.createElement(
TolgeeProvider,
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
{
tolgee,
fallback: "Loading",
ssr: {
language: "en-US",
staticData: {
"en-US": enUSTranslations,
},
},
},
React.createElement(Story)
);
};

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.21 AS base
FROM node:22-alpine3.22 AS base
#
## step 1: Prune monorepo
@@ -30,9 +30,13 @@ COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
RUN chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument
ARG NODE_OPTIONS="--max_old_space_size=4096"
ARG NODE_OPTIONS="--max_old_space_size=8192"
ENV NODE_OPTIONS=${NODE_OPTIONS}
# Target architecture - automatically provided by Docker in multi-platform builds
# but needs explicit declaration for some build systems (like Depot)
ARG TARGETARCH
# Set the working directory
WORKDIR /app
@@ -57,6 +61,7 @@ RUN pnpm build --filter=@formbricks/database
# This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version

View File

@@ -45,22 +45,11 @@ afterEach(() => {
});
describe("LandingSidebar component", () => {
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
const organization = { id: "o1", name: "orgOne" } as any;
const organizations = [
{ id: "o2", name: "betaOrg" },
{ id: "o1", name: "alphaOrg" },
] as any;
test("renders logo, avatar, and initial modal closed", () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
render(<LandingSidebar user={user} organization={organization} />);
// Formbricks logo
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
@@ -71,14 +60,7 @@ describe("LandingSidebar component", () => {
});
test("clicking logout triggers signOut", async () => {
render(
<LandingSidebar
isMultiOrgEnabled={false}
user={user}
organization={organization}
organizations={organizations}
/>
);
render(<LandingSidebar user={user} organization={organization} />);
// Open user dropdown by clicking on avatar trigger
const trigger = screen.getByTestId("avatar").parentElement;

View File

@@ -10,48 +10,27 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
interface LandingSidebarProps {
isMultiOrgEnabled: boolean;
user: TUser;
organization: TOrganization;
organizations: TOrganization[];
}
export const LandingSidebar = ({
isMultiOrgEnabled,
user,
organization,
organizations,
}: LandingSidebarProps) => {
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
router.push(`/organizations/${organizationId}/`);
};
const dropdownNavigation = [
{
label: t("common.documentation"),
@@ -61,13 +40,6 @@ export const LandingSidebar = ({
},
];
const currentOrganizationId = organization?.id;
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
const sortedOrganizations = useMemo(() => {
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
}, [organizations]);
return (
<aside
className={cn(
@@ -81,26 +53,27 @@ export const LandingSidebar = ({
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center gap-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</>
</div>
<button
type="button"
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
aria-haspopup="menu">
<ProfileAvatar userId={user.id} />
<div className="grow overflow-hidden">
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -112,7 +85,13 @@ export const LandingSidebar = ({
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<Link
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
@@ -121,7 +100,6 @@ export const LandingSidebar = ({
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -136,45 +114,6 @@ export const LandingSidebar = ({
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
{/* Organization Switch */}
{(isMultiOrgEnabled || organizations.length > 1) && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-lg">
<div>
<p>{currentOrganizationName}</p>
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
<DropdownMenuRadioGroup
value={currentOrganizationId}
onValueChange={(organizationId) =>
handleEnvironmentChangeByOrganization(organizationId)
}>
{sortedOrganizations.map((organization) => (
<DropdownMenuRadioItem
value={organization.id}
className="cursor-pointer rounded-lg"
key={organization.id}>
{organization.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{isMultiOrgEnabled && (
<DropdownMenuItem
onClick={() => setOpenCreateOrganizationModal(true)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.create_new_organization")}</span>
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>

View File

@@ -1,3 +1,4 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -15,6 +16,7 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
isPendingDowngrade: false,
fallbackLevel: "live",
}),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
}));
vi.mock("@/lib/constants", () => ({
@@ -101,16 +103,32 @@ vi.mock("@/lib/constants", () => ({
AUDIT_LOG_ENABLED: true,
}));
vi.mock("@/lib/getPublicUrl", () => ({
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
}));
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
LandingSidebar: () => <div data-testid="landing-sidebar" />,
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
}));
vi.mock("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service");
vi.mock("@/lib/membership/service");
vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_STUB"),
usePathname: vi.fn(() => "/organizations/org1"),
useRouter: vi.fn(() => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
})),
}));
// Mock the React cache function
@@ -142,6 +160,7 @@ describe("Page component", () => {
isPendingDowngrade: false,
fallbackLevel: "live",
}),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
@@ -163,6 +182,7 @@ describe("Page component", () => {
isPendingDowngrade: false,
fallbackLevel: "live",
}),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
}));
const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } });
@@ -173,10 +193,16 @@ describe("Page component", () => {
test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: { id: "org1" },
organization: { id: "org1", billing: { plan: "free" } },
} as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
organizationId: "org1",
userId: "user1",
accepted: true,
role: "member",
} as any);
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || ""
);
@@ -188,11 +214,13 @@ describe("Page component", () => {
isPendingDowngrade: false,
fallbackLevel: "live",
}),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
}));
const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
});

View File

@@ -1,7 +1,11 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
@@ -22,24 +26,38 @@ const Page = async (props) => {
const organizations = await getOrganizationsByUserId(session.user.id);
const { features } = await getEnterpriseLicense();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember } = getAccessFlags(membership?.role);
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar
user={user}
organization={organization}
isMultiOrgEnabled={isMultiOrgEnabled}
organizations={organizations}
/>
<LandingSidebar user={user} organization={organization} />
<div className="flex-1">
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_projects_warning_title")}
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
/>
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
<ProjectAndOrgSwitch
currentOrganizationId={organization.id}
organizations={organizations}
projects={[]}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_projects_warning_title")}
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
/>
</div>
</div>
</div>
</div>

View File

@@ -18,11 +18,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
// Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({
@@ -58,7 +53,6 @@ describe("SurveyEditorEnvironmentLayout", () => {
render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
});

View File

@@ -1,6 +1,5 @@
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation";
@@ -32,7 +31,6 @@ const SurveyEditorEnvironmentLayout = async (props) => {
user={user}
organization={organization}>
<div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
</EnvironmentIdBaseLayout>

View File

@@ -50,7 +50,7 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses);
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
const form = useForm<TActionClassInput>({
defaultValues: {

View File

@@ -1,3 +1,5 @@
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -5,9 +7,7 @@ import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import {
getAccessControlPermission,
@@ -20,11 +20,7 @@ import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import {
TOrganization,
TOrganizationBilling,
TOrganizationBillingPlanLimits,
} from "@formbricks/types/organizations";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
@@ -35,16 +31,12 @@ vi.mock("@/lib/environment/service", () => ({
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(),
getOrganizationsByUserId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(),
}));
vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(),
}));
vi.mock("@/lib/project/service", () => ({
getUserProjects: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(),
}));
@@ -64,6 +56,22 @@ vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
getOrganizationsByUserId: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
getProjectsByUserId: vi.fn(),
}));
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
organization: {
findMany: vi.fn(),
},
},
}));
let mockIsFormbricksCloud = false;
let mockIsDevelopment = false;
@@ -90,10 +98,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", ()
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
}));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
}));
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
}));
@@ -113,7 +117,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -124,12 +127,10 @@ const mockUser = {
const mockOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
} as unknown as TOrganizationBilling,
plan: "free",
limits: {},
},
} as unknown as TOrganization;
const mockEnvironment: TEnvironment = {
@@ -192,9 +193,11 @@ describe("EnvironmentLayout", () => {
beforeEach(() => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
{ id: mockOrganization.id, name: mockOrganization.name },
]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
@@ -241,33 +244,6 @@ describe("EnvironmentLayout", () => {
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
});
test("renders DevEnvironmentBanner in development environment", async () => {
const devEnvironment = { ...mockEnvironment, type: "development" as const };
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
mockIsDevelopment = true;
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("dev-banner")).toBeInTheDocument();
});
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true;
vi.resetModules();
@@ -315,32 +291,6 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("passes isAccessControlAllowed 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("is-access-control-allowed")).toHaveTextContent("true");
expect(vi.mocked(getAccessControlPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
});
test("handles empty organizationTeams array", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
vi.resetModules();
@@ -480,7 +430,7 @@ describe("EnvironmentLayout", () => {
});
test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getUserProjects).mockResolvedValue(null as any);
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({

View File

@@ -1,5 +1,7 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -8,9 +10,7 @@ import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import {
@@ -18,7 +18,6 @@ import {
getOrganizationProjectsLimit,
} 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";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server";
@@ -51,8 +50,14 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("common.environment_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
if (!currentUserMembership) {
throw new Error(t("common.membership_not_found"));
}
const membershipRole = currentUserMembership?.role;
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getUserProjects(user.id, organization.id),
getProjectsByUserId(user.id, currentUserMembership),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]);
@@ -61,8 +66,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
throw new Error(t("environments.projects_environments_organizations_not_found"));
}
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
const membershipRole = currentUserMembership?.role;
const { isMember } = getAccessFlags(membershipRole);
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
@@ -87,10 +90,17 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
// Find the current project from the projects array
const project = projects.find((p) => p.id === environment.projectId);
if (!project) {
throw new Error(t("common.project_not_found"));
}
const { isManager, isOwner } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
<DevEnvironmentBanner environment={environment} />
{IS_FORMBRICKS_CLOUD && (
<LimitsReachedBanner
organization={organization}
@@ -112,21 +122,25 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
<MainNavigation
environment={environment}
organization={organization}
organizations={organizations}
projects={projects}
organizationProjectsLimit={organizationProjectsLimit}
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole}
isMultiOrgEnabled={isMultiOrgEnabled}
isLicenseActive={active}
isAccessControlAllowed={isAccessControlAllowed}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<TopControlBar
environment={environment}
environments={environments}
currentOrganizationId={organization.id}
organizations={organizations}
currentProjectId={project.id}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={active}
isOwnerOrManager={isOwnerOrManager}
isAccessControlAllowed={isAccessControlAllowed}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>

View File

@@ -1,5 +1,4 @@
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,23 +51,6 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
}));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({
isCollapsed,
organizationTeams,
isAccessControlAllowed,
}: {
isCollapsed: boolean;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
}) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed.toString()}</div>
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
}));
@@ -111,7 +93,6 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
@@ -178,13 +159,11 @@ describe("MainNavigation", () => {
test("renders expanded by default and collapses on toggle", async () => {
render(<MainNavigation {...defaultProps} />);
const projectSwitcher = screen.getByTestId("project-switcher");
// Assuming the toggle button is the only one initially without an accessible name
// A more specific selector like data-testid would be better if available.
const toggleButton = screen.getByRole("button", { name: "" });
// Check initial state (expanded)
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Check localStorage is not set initially after clear()
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
@@ -195,7 +174,6 @@ describe("MainNavigation", () => {
// Check state after first toggle (collapsed)
await waitFor(() => {
// Check that the attribute eventually becomes true
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
});
@@ -210,7 +188,6 @@ describe("MainNavigation", () => {
// Check state after second toggle (expanded)
await waitFor(() => {
// Check that the attribute eventually becomes false
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
// Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
});
@@ -246,8 +223,6 @@ describe("MainNavigation", () => {
expect(screen.getByText("common.account")).toBeInTheDocument();
});
expect(screen.getByText("common.organization")).toBeInTheDocument();
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
expect(screen.getByText("common.documentation")).toBeInTheDocument();
expect(screen.getByText("common.logout")).toBeInTheDocument();
@@ -268,46 +243,6 @@ describe("MainNavigation", () => {
});
});
test("handles organization switching", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
await userEvent.click(org2Item);
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
});
test("opens create organization modal", async () => {
render(<MainNavigation {...defaultProps} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for the initial dropdown items
await waitFor(() => {
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
});
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
await userEvent.click(createOrgButton);
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
});
test("hides new version banner for members or if no new version", async () => {
// Test for member
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
@@ -335,34 +270,25 @@ describe("MainNavigation", () => {
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
});
test("shows billing link and hides license link in cloud", async () => {
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
await userEvent.click(userTrigger);
// Wait for dropdown items
await waitFor(() => {
expect(screen.getByText("common.billing")).toBeInTheDocument();
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("true");
// Test basic navigation structure is rendered (aside element with complementary role)
expect(screen.getByRole("complementary")).toBeInTheDocument();
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
});
test("handles no organizationTeams", () => {
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
// Test that navigation renders correctly with no teams
expect(screen.getByRole("complementary")).toBeInTheDocument();
});
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} isAccessControlAllowed={false} />);
render(<MainNavigation {...defaultProps} />);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
// Test that navigation renders correctly with access control disabled
expect(screen.getByRole("complementary")).toBeInTheDocument();
});
});

View File

@@ -2,26 +2,17 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
import FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn";
import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
@@ -30,18 +21,14 @@ import {
BlocksIcon,
ChevronRightIcon,
Cog,
CreditCardIcon,
KeyIcon,
LogOutIcon,
MessageCircle,
MousePointerClick,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
PlusIcon,
RocketIcon,
UserCircleIcon,
UserIcon,
UsersIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -50,55 +37,40 @@ import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
import packageJson from "../../../../../package.json";
interface NavigationProps {
environment: TEnvironment;
organizations: TOrganization[];
user: TUser;
organization: TOrganization;
projects: TProject[];
isMultiOrgEnabled: boolean;
projects: { id: string; name: string }[];
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
isAccessControlAllowed: boolean;
}
export const MainNavigation = ({
environment,
organizations,
organization,
user,
projects,
isMultiOrgEnabled,
membershipRole,
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
isAccessControlAllowed,
}: NavigationProps) => {
const router = useRouter();
const pathname = usePathname();
const { t } = useTranslate();
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const isPricingDisabled = isMember;
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
@@ -119,40 +91,11 @@ export const MainNavigation = ({
}, [isCollapsed]);
useEffect(() => {
if (organization && organization.name !== "") {
setCurrentOrganizationName(organization.name);
setCurrentOrganizationId(organization.id);
// Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
}
}, [organization]);
const sortedOrganizations = useMemo(() => {
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
}, [organizations]);
const sortedProjects = useMemo(() => {
const channelOrder: (string | null)[] = ["website", "app", "link", null];
const groupedProjects = projects.reduce(
(acc, project) => {
const channel = project.config.channel;
const key = channel !== null ? channel : "null";
acc[key] = acc[key] || [];
acc[key].push(project);
return acc;
},
{} as Record<string, typeof projects>
);
Object.keys(groupedProjects).forEach((channel) => {
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
});
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
}, [projects]);
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
router.push(`/organizations/${organizationId}/`);
};
}, [pathname]);
const mainNavigation = useMemo(
() => [
@@ -197,29 +140,18 @@ export const MainNavigation = ({
href: `/environments/${environment.id}/settings/profile`,
icon: UserCircleIcon,
},
{
label: t("common.organization"),
href: `/environments/${environment.id}/settings/general`,
icon: UsersIcon,
},
{
label: t("common.billing"),
href: `/environments/${environment.id}/settings/billing`,
hidden: !isFormbricksCloud,
icon: CreditCardIcon,
},
{
label: t("common.license"),
href: `/environments/${environment.id}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled,
icon: KeyIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
target: "_blank",
icon: ArrowUpRightIcon,
},
];
useEffect(() => {
@@ -229,7 +161,7 @@ export const MainNavigation = ({
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (currentVersionTag !== latestVersionTag) {
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
setLatestVersion(latestVersionTag);
}
}
@@ -245,8 +177,7 @@ export const MainNavigation = ({
<aside
className={cn(
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
)}>
<div>
{/* Logo and Toggle */}
@@ -312,23 +243,6 @@ export const MainNavigation = ({
</Link>
)}
{/* Project Switch */}
{!isBilling && (
<ProjectSwitcher
environmentId={environment.id}
projects={sortedProjects}
project={project}
isCollapsed={isCollapsed}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isTextVisible={isTextVisible}
organization={organization}
organizationProjectsLimit={organizationProjectsLimit}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
@@ -337,12 +251,11 @@ export const MainNavigation = ({
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div
tabIndex={0}
className={cn(
"flex cursor-pointer flex-row items-center gap-3",
isCollapsed ? "justify-center px-2" : "px-4"
)}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<ProfileAvatar userId={user.id} />
{!isCollapsed && !isTextVisible && (
<>
<div
@@ -354,11 +267,7 @@ export const MainNavigation = ({
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
</div>
<ChevronRightIcon
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
@@ -376,24 +285,20 @@ export const MainNavigation = ({
align="end">
{/* Dropdown Items */}
{dropdownNavigation.map(
(link) =>
!link.hidden && (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
)
)}
{dropdownNavigation.map((link) => (
<Link
href={link.href}
target={link.target}
className="flex w-full items-center"
key={link.label}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
</DropdownMenuItem>
</Link>
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const route = await signOutWithAudit({
@@ -409,55 +314,12 @@ export const MainNavigation = ({
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")}
</DropdownMenuItem>
{/* Organization Switch */}
{(isMultiOrgEnabled || organizations.length > 1) && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="rounded-lg">
<div>
<p>{currentOrganizationName}</p>
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
</div>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
<DropdownMenuRadioGroup
value={currentOrganizationId}
onValueChange={(organizationId) =>
handleEnvironmentChangeByOrganization(organizationId)
}>
{sortedOrganizations.map((organization) => (
<DropdownMenuRadioItem
value={organization.id}
className="cursor-pointer rounded-lg"
key={organization.id}>
{organization.name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{isMultiOrgEnabled && (
<DropdownMenuItem
onClick={() => setShowCreateOrganizationModal(true)}
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
<span>{t("common.create_new_organization")}</span>
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</aside>
)}
<CreateOrganizationModal
open={showCreateOrganizationModal}
setOpen={(val) => setShowCreateOrganizationModal(val)}
/>
</>
);
};

View File

@@ -1,64 +0,0 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TopControlBar } from "./TopControlBar";
// Mock the child component
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
}));
const mockEnvironment: TEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "proj1",
appSetupCompleted: true,
};
const mockEnvironments: TEnvironment[] = [
mockEnvironment,
{ ...mockEnvironment, id: "env2", type: "development" },
];
const mockMembershipRole: TOrganizationRole = "owner";
const mockProjectPermission = "manage";
describe("TopControlBar", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders correctly and passes props to TopControlButtons", () => {
render(
<TopControlBar
environment={mockEnvironment}
environments={mockEnvironments}
membershipRole={mockMembershipRole}
projectPermission={mockProjectPermission}
/>
);
// Check if the main div is rendered
const mainDiv = screen.getByTestId("fb__global-top-control-bar");
expect(mainDiv).toHaveClass("flex h-14 w-full items-center justify-end bg-slate-50 px-6");
// Check if the mocked child component is rendered
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
// Check if the child component received the correct props
expect(TopControlButtons).toHaveBeenCalledWith(
{
environment: mockEnvironment,
environments: mockEnvironments,
membershipRole: mockMembershipRole,
projectPermission: mockProjectPermission,
},
undefined // Updated from {} to undefined
);
});
});

View File

@@ -1,34 +1,110 @@
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
"use client";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface SideBarProps {
environment: TEnvironment;
interface TopControlBarProps {
environments: TEnvironment[];
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentProjectId: string;
projects: { id: string; name: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null;
}
export const TopControlBar = ({
environment,
environments,
currentOrganizationId,
organizations,
currentProjectId,
projects,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
membershipRole,
projectPermission,
}: SideBarProps) => {
}: TopControlBarProps) => {
const { t } = useTranslate();
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const { environment } = useEnvironment();
return (
<div
className="flex h-14 w-full items-center justify-end bg-slate-50 px-6"
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<div className="shadow-xs z-10">
<div className="flex w-fit items-center space-x-2 py-2">
<TopControlButtons
environment={environment}
environments={environments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
</div>
<div className="flex items-center">
<ProjectAndOrgSwitch
currentEnvironmentId={environment.id}
environments={environments}
currentOrganizationId={currentOrganizationId}
organizations={organizations}
currentProjectId={currentProjectId}
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
<div className="z-50 flex items-center space-x-2">
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link
href="https://github.com/formbricks/formbricks/issues"
target="_blank"
aria-label={t("common.share_feedback")}
rel="noopener noreferrer">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
<CircleUserIcon />
</Link>
</Button>
</TooltipRenderer>
{isBilling || isReadOnly ? (
<></>
) : (
<TooltipRenderer tooltipContent={t("common.new_survey")}>
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
<Link
href={`/environments/${environment.id}/surveys/templates`}
aria-label={t("common.new_survey")}>
<PlusIcon />
</Link>
</Button>
</TooltipRenderer>
)}
</div>
</div>
);

View File

@@ -1,182 +0,0 @@
import { getAccessFlags } from "@/lib/membership/utils";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
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 { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
import { TopControlButtons } from "./TopControlButtons";
// Mock dependencies
const mockPush = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: mockPush })),
}));
vi.mock("@/lib/membership/utils", () => ({
getAccessFlags: vi.fn(),
}));
vi.mock("@/modules/ee/teams/utils/teams", () => ({
getTeamPermissionFlags: vi.fn(),
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
}));
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
return (
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
{children}
</Tag>
);
},
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
),
}));
vi.mock("lucide-react", () => ({
BugIcon: () => <div data-testid="bug-icon" />,
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
PlusIcon: () => <div data-testid="plus-icon" />,
}));
vi.mock("next/link", () => ({
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
<a href={href} target={target} data-testid="link-mock">
{children}
</a>
),
}));
// Mock data
const mockEnvironmentDev: TEnvironment = {
id: "dev-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironmentProd: TEnvironment = {
id: "prod-env-id",
createdAt: new Date(),
updatedAt: new Date(),
type: "production",
projectId: "project-id",
appSetupCompleted: true,
};
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
describe("TopControlButtons", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mocks for access flags
vi.mocked(getAccessFlags).mockReturnValue({
isOwner: false,
isMember: false,
isBilling: false,
} as any);
vi.mocked(getTeamPermissionFlags).mockReturnValue({
hasReadAccess: false,
} as any);
});
afterEach(() => {
cleanup();
});
const renderComponent = (
membershipRole?: TOrganizationRole,
projectPermission: any = null,
isBilling = false,
hasReadAccess = false
) => {
vi.mocked(getAccessFlags).mockReturnValue({
isMember: membershipRole === "member",
isBilling: isBilling,
isOwner: membershipRole === "owner",
} as any);
vi.mocked(getTeamPermissionFlags).mockReturnValue({
hasReadAccess: hasReadAccess,
} as any);
return render(
<TopControlButtons
environment={mockEnvironmentDev}
environments={mockEnvironments}
membershipRole={membershipRole}
projectPermission={projectPermission}
/>
);
};
test("renders correctly for Owner role", async () => {
renderComponent("owner");
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
// Check link
const link = screen.getByTestId("link-mock");
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
expect(link).toHaveAttribute("target", "_blank");
// Click account button
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
await userEvent.click(accountButton!);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
});
// Click new survey button
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
await userEvent.click(newSurveyButton!);
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
});
});
test("hides EnvironmentSwitch for Billing role", () => {
renderComponent(undefined, null, true); // isBilling = true
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
});
test("hides New Survey button for Billing role", () => {
renderComponent(undefined, null, true); // isBilling = true
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
});
test("hides New Survey button for read-only Member", () => {
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
});
test("shows New Survey button for Member with write access", () => {
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
});
});

View File

@@ -1,76 +0,0 @@
"use client";
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlButtonsProps {
environment: TEnvironment;
environments: TEnvironment[];
membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null;
}
export const TopControlButtons = ({
environment,
environments,
membershipRole,
projectPermission,
}: TopControlButtonsProps) => {
const { t } = useTranslate();
const router = useRouter();
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
return (
<div className="z-50 flex items-center space-x-2">
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button
variant="ghost"
size="icon"
className="h-fit w-fit bg-slate-50 p-1"
onClick={() => {
router.push(`/environments/${environment.id}/settings/profile`);
}}>
<CircleUserIcon />
</Button>
</TooltipRenderer>
{isBilling || isReadOnly ? (
<></>
) : (
<TooltipRenderer tooltipContent={t("common.new_survey")}>
<Button
variant="secondary"
size="icon"
className="h-fit w-fit p-1"
onClick={() => {
router.push(`/environments/${environment.id}/surveys/templates`);
}}>
<PlusIcon />
</Button>
</TooltipRenderer>
)}
</div>
);
};

View File

@@ -0,0 +1,349 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
// Mock the dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
// Mock the UI components
vi.mock("@/modules/ui/components/breadcrumb", () => ({
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
{children}
</li>
),
}));
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children, onOpenChange }: any) => (
<button
type="button"
data-testid="dropdown-menu"
onClick={() => onOpenChange?.(true)}
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
{children}
</button>
),
DropdownMenuContent: ({ children, ...props }: any) => (
<div data-testid="dropdown-content" {...props}>
{children}
</div>
),
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
<div
data-testid="dropdown-checkbox-item"
data-checked={checked}
onClick={onClick}
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
role="menuitemcheckbox"
aria-checked={checked}
tabIndex={0}
{...props}>
{children}
</div>
),
DropdownMenuTrigger: ({ children, ...props }: any) => (
<button data-testid="dropdown-trigger" {...props}>
{children}
</button>
),
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
}));
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
TooltipTrigger: ({ children, asChild }: any) => (
<div data-testid="tooltip-trigger" data-as-child={asChild}>
{children}
</div>
),
TooltipContent: ({ children, className }: any) => (
<div data-testid="tooltip-content" className={className}>
{children}
</div>
),
}));
// Mock Lucide React icons
vi.mock("lucide-react", () => ({
Code2Icon: ({ className, strokeWidth }: any) => {
const isHeader = className?.includes("mr-2");
return (
<svg
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
className={className}
strokeWidth={strokeWidth}>
<title>Code2 Icon</title>
</svg>
);
},
ChevronDownIcon: ({ className, strokeWidth }: any) => (
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
<title>ChevronDown Icon</title>
</svg>
),
CircleHelpIcon: ({ className }: any) => (
<svg data-testid="circle-help-icon" className={className}>
<title>CircleHelp Icon</title>
</svg>
),
Loader2: ({ className }: any) => (
<svg data-testid="loader-2-icon" className={className}>
<title>Loader2 Icon</title>
</svg>
),
}));
describe("EnvironmentBreadcrumb", () => {
const mockPush = vi.fn();
const mockRouter = {
push: mockPush,
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
};
const mockProductionEnvironment: TEnvironment = {
id: "env-prod-1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
type: "production",
projectId: "project-1",
appSetupCompleted: true,
};
const mockDevelopmentEnvironment: TEnvironment = {
id: "env-dev-1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
type: "development",
projectId: "project-1",
appSetupCompleted: true,
};
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
beforeEach(() => {
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders environment breadcrumb with production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
});
test("renders environment breadcrumb with development environment and shows tooltip", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
/>
);
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
});
test("highlights breadcrumb item for development environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
/>
);
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
});
test("does not highlight breadcrumb item for production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
});
test("shows chevron down icon when dropdown is open", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
await waitFor(() => {
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
});
});
test("renders dropdown content with environment options", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
});
test("renders all environment options in dropdown", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
expect(checkboxItems).toHaveLength(2);
// Check production environment option
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
expect(productionOption).toBeInTheDocument();
expect(productionOption).toHaveAttribute("data-checked", "true");
// Check development environment option
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
expect(developmentOption).toBeInTheDocument();
expect(developmentOption).toHaveAttribute("data-checked", "false");
});
test("handles environment change when clicking dropdown option", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
expect(developmentOption).toBeInTheDocument();
await user.click(developmentOption!);
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
});
test("capitalizes environment type in display", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
const environmentSpans = screen.getAllByText("production");
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
expect(triggerSpan).toHaveClass("capitalize");
});
test("tooltip shows correct content for development environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockDevelopmentEnvironment.id}
/>
);
const tooltipContent = screen.getByTestId("tooltip-content");
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
});
test("renders without tooltip for production environment", () => {
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
});
test("sets breadcrumb item as active when dropdown is open", async () => {
const user = userEvent.setup();
render(
<EnvironmentBreadcrumb
environments={mockEnvironments}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
// Initially not active
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
// Open dropdown
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
// Should be active when dropdown is open
breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
});
test("handles single environment scenario", () => {
const singleEnvironment = [mockProductionEnvironment];
render(
<EnvironmentBreadcrumb
environments={singleEnvironment}
currentEnvironmentId={mockProductionEnvironment.id}
/>
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
});
});

View File

@@ -0,0 +1,93 @@
"use client";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironmentId,
}: {
environments: { id: string; type: string }[];
currentEnvironmentId: string;
}) => {
const { t } = useTranslate();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
if (!currentEnvironment) {
return null;
}
const handleEnvironmentChange = (environmentId: string) => {
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.id === currentEnvironment.id}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};

View File

@@ -0,0 +1,560 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { usePathname, useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { OrganizationBreadcrumb } from "./organization-breadcrumb";
// Mock the dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
usePathname: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="create-organization-modal">
<button type="button" onClick={() => setOpen(false)}>
Close Modal
</button>
Create Organization Modal
</div>
) : null,
}));
// Mock the UI components
vi.mock("@/modules/ui/components/breadcrumb", () => ({
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
{children}
</li>
),
}));
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children, onOpenChange }: any) => (
<div
data-testid="dropdown-menu"
onClick={() => onOpenChange?.(true)}
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}
role="button"
tabIndex={0}>
{children}
</div>
),
DropdownMenuContent: ({ children, ...props }: any) => (
<div data-testid="dropdown-content" {...props}>
{children}
</div>
),
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
<div
data-testid="dropdown-checkbox-item"
data-checked={checked}
onClick={onClick}
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
role="menuitemcheckbox"
aria-checked={checked}
tabIndex={0}
{...props}>
{children}
</div>
),
DropdownMenuTrigger: ({ children, ...props }: any) => (
<button data-testid="dropdown-trigger" {...props}>
{children}
</button>
),
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
}));
// Mock Lucide React icons
vi.mock("lucide-react", () => ({
BuildingIcon: ({ className, strokeWidth }: any) => {
const isHeader = className?.includes("mr-2");
return (
<svg
data-testid={isHeader ? "building-header-icon" : "building-icon"}
className={className}
strokeWidth={strokeWidth}>
<title>Building Icon</title>
</svg>
);
},
ChevronDownIcon: ({ className, strokeWidth }: any) => (
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
<title>ChevronDown Icon</title>
</svg>
),
ChevronRightIcon: ({ className, strokeWidth }: any) => (
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
<title>ChevronRight Icon</title>
</svg>
),
PlusIcon: ({ className }: any) => (
<svg data-testid="plus-icon" className={className}>
<title>Plus Icon</title>
</svg>
),
SettingsIcon: ({ className }: any) => (
<svg data-testid="settings-icon" className={className}>
<title>Settings Icon</title>
</svg>
),
Loader2: ({ className }: any) => (
<svg data-testid="loader-2-icon" className={className}>
<title>Loader2 Icon</title>
</svg>
),
}));
describe("OrganizationBreadcrumb", () => {
const mockPush = vi.fn();
const mockRouter = {
push: mockPush,
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
};
const mockOrganization1: TOrganization = {
id: "org-1",
name: "Test Organization 1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
billing: {
plan: "free",
stripeCustomerId: null,
} as unknown as TOrganizationBilling,
isAIEnabled: false,
};
const mockOrganization2: TOrganization = {
id: "org-2",
name: "Test Organization 2",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
billing: {
plan: "startup",
stripeCustomerId: null,
} as unknown as TOrganizationBilling,
isAIEnabled: true,
};
const mockOrganizations = [mockOrganization1, mockOrganization2];
const currentEnvironmentId = "env-123";
beforeEach(() => {
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
vi.mocked(usePathname).mockReturnValue("/environments/env-123/");
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("Single Organization Setup", () => {
test("renders organization breadcrumb without dropdown for single org", () => {
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={[mockOrganization1]}
isMultiOrgEnabled={false}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
expect(screen.getByText("Test Organization 1")).toBeInTheDocument();
});
test("shows organization settings without organization switcher", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={[mockOrganization1]}
isMultiOrgEnabled={false}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
expect(screen.queryByText("common.choose_organization")).not.toBeInTheDocument();
});
});
describe("Multi Organization Setup", () => {
test("renders organization breadcrumb with dropdown for multi org", () => {
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
expect(screen.getAllByText("Test Organization 1")).toHaveLength(2); // trigger + dropdown option
});
test("shows chevron icons correctly", () => {
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
// Should show chevron right when closed
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
});
test("shows chevron down when dropdown is open", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
await waitFor(() => {
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
});
});
test("renders organization selector in dropdown", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
expect(checkboxItems.length).toBeGreaterThanOrEqual(2); // Organizations + create new option + settings
});
test("handles organization change when clicking dropdown option", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
const org2Option = checkboxItems.find((item) => item.textContent?.includes("Test Organization 2"));
expect(org2Option).toBeInTheDocument();
await user.click(org2Option!);
expect(mockPush).toHaveBeenCalledWith("/organizations/org-2/");
});
test("shows create new organization option when multi org is enabled", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const createOrgOption = screen.getByText("common.create_new_organization");
expect(createOrgOption).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
});
test("opens create organization modal when clicking create new option", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const createOrgOption = screen.getByText("common.create_new_organization");
await user.click(createOrgOption);
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
});
test("hides create new organization option when multi org is disabled", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={false}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.queryByText("common.create_new_organization")).not.toBeInTheDocument();
});
});
describe("Organization Settings", () => {
test("renders all organization settings options", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
isFormbricksCloud={true}
isMember={false}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
expect(screen.getByText("common.general")).toBeInTheDocument();
expect(screen.getByText("common.teams")).toBeInTheDocument();
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
expect(screen.getByText("common.billing")).toBeInTheDocument();
});
test("handles navigation to organization settings", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const generalOption = screen.getByText("common.general");
await user.click(generalOption);
expect(mockPush).toHaveBeenCalledWith(`/environments/${currentEnvironmentId}/settings/general`);
});
test("marks current settings page as checked", async () => {
vi.mocked(usePathname).mockReturnValue("/environments/env-123/settings/teams");
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
const teamsOption = checkboxItems.find((item) => item.textContent?.includes("common.teams"));
expect(teamsOption).toBeInTheDocument();
expect(teamsOption).toHaveAttribute("data-checked", "true");
});
});
describe("Edge Cases", () => {
test("handles single organization with multi org enabled", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={[mockOrganization1]}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
// Should still show organization selector since multi org is enabled
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
expect(screen.getByText("common.create_new_organization")).toBeInTheDocument();
});
test("shows separator between organization switcher and settings", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument();
});
test("sets breadcrumb item as active when dropdown is open", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
// Initially not active
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
// Open dropdown
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
// Should be active when dropdown is open
breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
});
test("closes create organization modal correctly", async () => {
const user = userEvent.setup();
render(
<OrganizationBreadcrumb
currentOrganizationId={mockOrganization1.id}
organizations={mockOrganizations}
isMultiOrgEnabled={true}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={true}
isMember={false}
isOwnerOrManager={true}
/>
);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const createOrgOption = screen.getByText("common.create_new_organization");
await user.click(createOrgOption);
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
const closeButton = screen.getByText("Close Modal");
await user.click(closeButton);
expect(screen.queryByTestId("create-organization-modal")).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,173 @@
"use client";
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react";
import {
BuildingIcon,
ChevronDownIcon,
ChevronRightIcon,
Loader2,
PlusIcon,
SettingsIcon,
} from "lucide-react";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
interface OrganizationBreadcrumbProps {
currentOrganizationId: string;
organizations: { id: string; name: string }[];
isMultiOrgEnabled: boolean;
currentEnvironmentId?: string;
isFormbricksCloud: boolean;
isMember: boolean;
isOwnerOrManager: boolean;
}
export const OrganizationBreadcrumb = ({
currentOrganizationId,
organizations,
isMultiOrgEnabled,
currentEnvironmentId,
isFormbricksCloud,
isMember,
isOwnerOrManager,
}: OrganizationBreadcrumbProps) => {
const { t } = useTranslate();
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
const pathname = usePathname();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
if (!currentOrganization) {
return null;
}
const handleOrganizationChange = (organizationId: string) => {
setIsLoading(true);
router.push(`/organizations/${organizationId}/`);
};
// Hide organization dropdown for single org setups (on-premise)
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
const organizationSettings = [
{
id: "general",
label: t("common.general"),
href: `/environments/${currentEnvironmentId}/settings/general`,
},
{
id: "teams",
label: t("common.teams"),
href: `/environments/${currentEnvironmentId}/settings/teams`,
},
{
id: "api-keys",
label: t("common.api_keys"),
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
hidden: !isFormbricksCloud,
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isMember,
},
];
return (
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="organizationDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{currentOrganization.name}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isOrganizationDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
{showOrganizationDropdown && (
<>
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<BuildingIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_organization")}
</div>
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}
checked={org.id === currentOrganization.id}
onClick={() => handleOrganizationChange(org.id)}
className="cursor-pointer">
{org.name}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isMultiOrgEnabled && (
<DropdownMenuCheckboxItem
onClick={() => setOpenCreateOrganizationModal(true)}
className="cursor-pointer">
<span>{t("common.create_new_organization")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</>
)}
{currentEnvironmentId && (
<div>
<DropdownMenuSeparator />
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<SettingsIcon className="mr-2 inline h-4 w-4" />
{t("common.organization_settings")}
</div>
{organizationSettings.map((setting) => {
return setting.hidden ? null : (
<DropdownMenuCheckboxItem
key={setting.id}
checked={pathname.includes(setting.id)}
hidden={setting.hidden}
onClick={() => router.push(setting.href)}
className="cursor-pointer">
{setting.label}
</DropdownMenuCheckboxItem>
);
})}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>
{openCreateOrganizationModal && (
<CreateOrganizationModal
open={openCreateOrganizationModal}
setOpen={setOpenCreateOrganizationModal}
/>
)}
</BreadcrumbItem>
);
};

View File

@@ -0,0 +1,351 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { ProjectAndOrgSwitch } from "./project-and-org-switch";
// Mock the individual breadcrumb components
vi.mock("@/app/(app)/environments/[environmentId]/components/organization-breadcrumb", () => ({
OrganizationBreadcrumb: ({
currentOrganizationId,
organizations,
isMultiOrgEnabled,
currentEnvironmentId,
}: any) => {
const currentOrganization = organizations.find((org: any) => org.id === currentOrganizationId);
return (
<div data-testid="organization-breadcrumb">
<div>Organization: {currentOrganization?.name}</div>
<div>Organizations Count: {organizations.length}</div>
<div>Multi Org: {isMultiOrgEnabled ? "Enabled" : "Disabled"}</div>
<div>Environment ID: {currentEnvironmentId}</div>
</div>
);
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/project-breadcrumb", () => ({
ProjectBreadcrumb: ({
currentProjectId,
projects,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
}: any) => {
const currentProject = projects.find((project: any) => project.id === currentProjectId);
return (
<div data-testid="project-breadcrumb">
<div>Project: {currentProject?.name}</div>
<div>Projects Count: {projects.length}</div>
<div>Owner/Manager: {isOwnerOrManager ? "Yes" : "No"}</div>
<div>Project Limit: {organizationProjectsLimit}</div>
<div>Formbricks Cloud: {isFormbricksCloud ? "Yes" : "No"}</div>
<div>License Active: {isLicenseActive ? "Yes" : "No"}</div>
<div>Organization ID: {currentOrganizationId}</div>
<div>Environment ID: {currentEnvironmentId}</div>
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
</div>
);
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/components/environment-breadcrumb", () => ({
EnvironmentBreadcrumb: ({ environments, currentEnvironmentId }: any) => {
const currentEnvironment = environments.find((env: any) => env.id === currentEnvironmentId);
return (
<div data-testid="environment-breadcrumb">
<div>Environment: {currentEnvironment?.type}</div>
<div>Environments Count: {environments.length}</div>
<div>Environment ID: {currentEnvironment?.id}</div>
</div>
);
},
}));
// Mock the UI components
vi.mock("@/modules/ui/components/breadcrumb", () => ({
Breadcrumb: ({ children }: any) => (
<nav data-testid="breadcrumb" aria-label="breadcrumb">
{children}
</nav>
),
BreadcrumbList: ({ children, className }: any) => (
<ol data-testid="breadcrumb-list" className={className}>
{children}
</ol>
),
}));
describe("ProjectAndOrgSwitch", () => {
const mockOrganization1 = {
id: "org-1",
name: "Test Organization 1",
};
const mockOrganization2 = {
id: "org-2",
name: "Test Organization 2",
};
const mockProject1 = {
id: "proj-1",
name: "Test Project 1",
};
const mockProject2 = {
id: "proj-2",
name: "Test Project 2",
};
const mockEnvironment1: TEnvironment = {
id: "env-1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
type: "production",
projectId: "proj-1",
appSetupCompleted: true,
};
const mockEnvironment2: TEnvironment = {
id: "env-2",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
type: "development",
projectId: "proj-1",
appSetupCompleted: true,
};
const defaultProps = {
currentOrganizationId: "org-1",
organizations: [mockOrganization1, mockOrganization2],
currentProjectId: "proj-1",
projects: [mockProject1, mockProject2],
currentEnvironmentId: "env-1",
environments: [mockEnvironment1, mockEnvironment2],
isMultiOrgEnabled: true,
organizationProjectsLimit: 5,
isFormbricksCloud: true,
isLicenseActive: false,
isOwnerOrManager: true,
isAccessControlAllowed: true,
isMember: true,
};
afterEach(() => {
cleanup();
});
describe("Basic Rendering", () => {
test("renders main breadcrumb structure", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
expect(screen.getByTestId("breadcrumb-list")).toBeInTheDocument();
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("aria-label", "breadcrumb");
});
test("applies correct CSS classes to breadcrumb list", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const breadcrumbList = screen.getByTestId("breadcrumb-list");
expect(breadcrumbList).toHaveClass("gap-0");
});
test("renders all three breadcrumb components", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
});
});
describe("Organization Breadcrumb Integration", () => {
test("passes correct props to organization breadcrumb", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 1");
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 2");
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Enabled");
expect(orgBreadcrumb).toHaveTextContent("Environment ID: env-1");
});
test("handles single organization setup", () => {
render(
<ProjectAndOrgSwitch
{...defaultProps}
organizations={[mockOrganization1]}
isMultiOrgEnabled={false}
/>
);
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 1");
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
});
});
describe("Project Breadcrumb Integration", () => {
test("passes correct props to project breadcrumb", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Project: Test Project 1");
expect(projectBreadcrumb).toHaveTextContent("Projects Count: 2");
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: Yes");
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 5");
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: Yes");
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-1");
expect(projectBreadcrumb).toHaveTextContent("Environment ID: env-1");
expect(projectBreadcrumb).toHaveTextContent("Access Control: Allowed");
});
test("handles non-owner/manager user", () => {
render(<ProjectAndOrgSwitch {...defaultProps} isOwnerOrManager={false} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
});
test("handles self-hosted setup", () => {
render(<ProjectAndOrgSwitch {...defaultProps} isFormbricksCloud={false} isLicenseActive={true} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
expect(projectBreadcrumb).toHaveTextContent("License Active: Yes");
});
test("handles access control restrictions", () => {
render(<ProjectAndOrgSwitch {...defaultProps} isAccessControlAllowed={false} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
});
});
describe("Environment Breadcrumb Integration", () => {
test("passes correct props to environment breadcrumb", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
expect(envBreadcrumb).toHaveTextContent("Environment: production");
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
});
test("handles development environment", () => {
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
expect(envBreadcrumb).toHaveTextContent("Environment: development");
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
});
test("handles single environment", () => {
render(<ProjectAndOrgSwitch {...defaultProps} environments={[mockEnvironment1]} />);
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
expect(envBreadcrumb).toHaveTextContent("Environments Count: 1");
});
});
describe("Props Propagation", () => {
test("correctly propagates organization limits", () => {
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={10} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 10");
});
test("correctly propagates current organization to project breadcrumb", () => {
render(<ProjectAndOrgSwitch {...defaultProps} currentOrganizationId="org-2" />);
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 2");
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-2");
});
});
describe("Edge Cases", () => {
test("handles zero project limit", () => {
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={0} />);
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 0");
});
test("handles all boolean props as false", () => {
render(
<ProjectAndOrgSwitch
{...defaultProps}
isMultiOrgEnabled={false}
isFormbricksCloud={false}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
/>
);
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
});
test("maintains component order in DOM", () => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
const breadcrumbList = screen.getByTestId("breadcrumb-list");
const children = Array.from(breadcrumbList.children);
expect(children[0]).toHaveAttribute("data-testid", "organization-breadcrumb");
expect(children[1]).toHaveAttribute("data-testid", "project-breadcrumb");
expect(children[2]).toHaveAttribute("data-testid", "environment-breadcrumb");
});
});
describe("TypeScript Props Interface", () => {
test("accepts all required props without error", () => {
// This test ensures the component accepts the full interface
expect(() => {
render(<ProjectAndOrgSwitch {...defaultProps} />);
}).not.toThrow();
});
test("works with minimal valid props", () => {
const minimalProps = {
currentOrganizationId: "org-1",
organizations: [mockOrganization1],
currentProjectId: "proj-1",
projects: [mockProject1],
currentEnvironmentId: "env-1",
environments: [mockEnvironment1],
isMultiOrgEnabled: false,
organizationProjectsLimit: 1,
isFormbricksCloud: false,
isLicenseActive: false,
isOwnerOrManager: false,
isAccessControlAllowed: false,
isMember: true,
};
expect(() => {
render(<ProjectAndOrgSwitch {...minimalProps} />);
}).not.toThrow();
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,77 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
import { useMemo } from "react";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
organizations: { id: string; name: string }[];
currentProjectId?: string;
projects: { id: string; name: string }[];
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isAccessControlAllowed: boolean;
isMember: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
organizations,
currentProjectId,
projects,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
}: ProjectAndOrgSwitchProps) => {
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
const sortedOrganizations = useMemo(
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
[organizations]
);
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
organizations={sortedOrganizations}
isMultiOrgEnabled={isMultiOrgEnabled}
currentEnvironmentId={currentEnvironmentId}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
projects={sortedProjects}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
{currentEnvironmentId && (
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};

View File

@@ -0,0 +1,497 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useRouter } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { ProjectBreadcrumb } from "./project-breadcrumb";
// Mock the dependencies
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
open ? (
<div data-testid="project-limit-modal">
<div>Project Limit: {projectLimit}</div>
<button onClick={() => setOpen(false)}>Close Limit Modal</button>
{buttons.map((button: any) => (
<button key={button.text} type="button" onClick={() => button.href && window.open(button.href)}>
{button.text}
</button>
))}
</div>
) : null,
}));
vi.mock("@/modules/projects/components/create-project-modal", () => ({
CreateProjectModal: ({ open, setOpen, organizationId, isAccessControlAllowed }: any) =>
open ? (
<div data-testid="create-project-modal">
<div>Organization: {organizationId}</div>
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
<button onClick={() => setOpen(false)}>Close Create Modal</button>
</div>
) : null,
}));
// Mock the UI components
vi.mock("@/modules/ui/components/breadcrumb", () => ({
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
{children}
</li>
),
}));
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children, onOpenChange }: any) => (
<button
type="button"
data-testid="dropdown-menu"
onClick={() => onOpenChange?.(true)}
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
{children}
</button>
),
DropdownMenuContent: ({ children, ...props }: any) => (
<div data-testid="dropdown-content" {...props}>
{children}
</div>
),
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
<div
data-testid="dropdown-checkbox-item"
data-checked={checked}
onClick={onClick}
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
role="menuitemcheckbox"
aria-checked={checked}
tabIndex={0}
{...props}>
{children}
</div>
),
DropdownMenuTrigger: ({ children, ...props }: any) => (
<button data-testid="dropdown-trigger" {...props}>
{children}
</button>
),
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
}));
// Mock Lucide React icons
vi.mock("lucide-react", () => ({
FolderOpenIcon: ({ className, strokeWidth }: any) => {
const isHeader = className?.includes("mr-2");
return (
<svg
data-testid={isHeader ? "folder-open-header-icon" : "folder-open-icon"}
className={className}
strokeWidth={strokeWidth}>
<title>FolderOpen Icon</title>
</svg>
);
},
ChevronDownIcon: ({ className, strokeWidth }: any) => (
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
<title>ChevronDown Icon</title>
</svg>
),
ChevronRightIcon: ({ className, strokeWidth }: any) => (
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
<title>ChevronRight Icon</title>
</svg>
),
PlusIcon: ({ className }: any) => (
<svg data-testid="plus-icon" className={className}>
<title>Plus Icon</title>
</svg>
),
Loader2: ({ className }: any) => (
<svg data-testid="loader-2-icon" className={className}>
<title>Loader2 Icon</title>
</svg>
),
}));
describe("ProjectBreadcrumb", () => {
const mockPush = vi.fn();
const mockRouter = {
push: mockPush,
replace: vi.fn(),
refresh: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
prefetch: vi.fn(),
};
const mockProject1 = {
id: "proj-1",
name: "Test Project 1",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
organizationId: "org-1",
languages: [],
} as unknown as TProject;
const mockProject2 = {
id: "proj-2",
name: "Test Project 2",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
organizationId: "org-1",
languages: [],
} as unknown as TProject;
const mockProjects = [mockProject1, mockProject2];
const mockOrganization: TOrganization = {
id: "org-1",
name: "Test Organization",
createdAt: new Date("2023-01-01"),
updatedAt: new Date("2023-01-01"),
billing: {
plan: "free",
stripeCustomerId: null,
} as unknown as TOrganizationBilling,
isAIEnabled: false,
};
const defaultProps = {
currentProjectId: "proj-1",
currentOrganizationId: "org-1",
projects: mockProjects,
isOwnerOrManager: true,
organizationProjectsLimit: 3,
isFormbricksCloud: true,
isLicenseActive: false,
currentEnvironmentId: "env-123",
isAccessControlAllowed: true,
};
beforeEach(() => {
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("Basic Rendering", () => {
test("renders project breadcrumb correctly", () => {
render(<ProjectBreadcrumb {...defaultProps} />);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
expect(screen.getByTestId("folder-open-icon")).toBeInTheDocument();
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
});
test("shows chevron icons correctly", () => {
render(<ProjectBreadcrumb {...defaultProps} />);
// Should show chevron right when closed
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
});
test("shows chevron down when dropdown is open", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
await waitFor(() => {
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
});
});
});
describe("Project Selection", () => {
test("renders dropdown content with project options", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
});
test("renders all project options in dropdown", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
// Find project options (excluding the add new project option)
const projectOptions = checkboxItems.filter((item) => item.textContent?.includes("Test Project"));
expect(projectOptions).toHaveLength(2);
// Check current project is marked as selected
const currentProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 1"));
expect(currentProjectOption).toHaveAttribute("data-checked", "true");
// Check other project is not selected
const otherProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
expect(otherProjectOption).toHaveAttribute("data-checked", "false");
});
test("handles project change when clicking dropdown option", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
const project2Option = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
expect(project2Option).toBeInTheDocument();
await user.click(project2Option!);
expect(mockPush).toHaveBeenCalledWith("/projects/proj-2/");
});
});
describe("Add New Project", () => {
test("shows add new project option when user is owner or manager", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.getByText("common.add_new_project")).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
});
test("hides add new project option when user is not owner or manager", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} isOwnerOrManager={false} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
expect(screen.queryByText("common.add_new_project")).not.toBeInTheDocument();
});
test("opens create project modal when within project limit", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
expect(screen.getByText("Organization: org-1")).toBeInTheDocument();
expect(screen.getByText("Access Control: Allowed")).toBeInTheDocument();
});
test("opens limit modal when exceeding project limit", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
};
render(<ProjectBreadcrumb {...props} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
expect(screen.getByText("Project Limit: 3")).toBeInTheDocument();
});
});
describe("Project Limit Modal", () => {
test("shows correct buttons for Formbricks Cloud with non-enterprise plan", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
isFormbricksCloud: true,
currentOrganization: {
...mockOrganization,
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
},
};
render(<ProjectBreadcrumb {...props} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
});
test("shows correct buttons for self-hosted with active license", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
isFormbricksCloud: false,
isLicenseActive: true,
};
render(<ProjectBreadcrumb {...props} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
expect(screen.getByText("common.cancel")).toBeInTheDocument();
});
test("closes limit modal correctly", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
};
render(<ProjectBreadcrumb {...props} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
const closeButton = screen.getByText("Close Limit Modal");
await user.click(closeButton);
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
});
});
describe("Create Project Modal", () => {
test("closes create project modal correctly", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
const closeButton = screen.getByText("Close Create Modal");
await user.click(closeButton);
expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
});
test("passes correct props to create project modal", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} isAccessControlAllowed={false} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
expect(screen.getByText("Access Control: Not Allowed")).toBeInTheDocument();
});
});
describe("Edge Cases", () => {
test("handles single project scenario", () => {
render(<ProjectBreadcrumb {...defaultProps} projects={[mockProject1]} />);
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
});
test("sets breadcrumb item as active when dropdown is open", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} />);
// Initially not active
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
// Open dropdown
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
// Should be active when dropdown is open
breadcrumbItem = screen.getByTestId("breadcrumb-item");
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
});
test("handles project limit of zero", async () => {
const user = userEvent.setup();
render(<ProjectBreadcrumb {...defaultProps} organizationProjectsLimit={0} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
// Should show limit modal even with 0 projects when limit is 0
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
expect(screen.getByText("Project Limit: 0")).toBeInTheDocument();
});
test("handles enterprise plan on Formbricks Cloud", async () => {
const user = userEvent.setup();
const props = {
...defaultProps,
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
organizationProjectsLimit: 3,
currentOrganization: {
...mockOrganization,
billing: { ...mockOrganization.billing, plan: "enterprise" } as unknown as TOrganizationBilling,
},
};
render(<ProjectBreadcrumb {...props} />);
const dropdownMenu = screen.getByTestId("dropdown-menu");
await user.click(dropdownMenu);
const addProjectOption = screen.getByText("common.add_new_project");
await user.click(addProjectOption);
// Should show self-hosted style buttons for enterprise plan
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,160 @@
"use client";
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface ProjectBreadcrumbProps {
currentProjectId: string;
projects: { id: string; name: string }[];
isOwnerOrManager: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
currentOrganizationId: string;
currentEnvironmentId: string;
isAccessControlAllowed: boolean;
}
export const ProjectBreadcrumb = ({
currentProjectId,
projects,
isOwnerOrManager,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
currentOrganizationId,
currentEnvironmentId,
isAccessControlAllowed,
}: ProjectBreadcrumbProps) => {
const { t } = useTranslate();
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
const [openLimitModal, setOpenLimitModal] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const currentProject = projects.find((project) => project.id === currentProjectId);
if (!currentProject) {
return null;
}
const handleProjectChange = (projectId: string) => {
setIsLoading(true);
router.push(`/projects/${projectId}/`);
};
const handleAddProject = () => {
if (projects.length >= organizationProjectsLimit) {
setOpenLimitModal(true);
return;
}
setOpenCreateProjectModal(true);
};
const LimitModalButtons = (): [ModalButton, ModalButton] => {
if (isFormbricksCloud) {
return [
{
text: t("environments.settings.billing.upgrade"),
href: `/environments/${currentEnvironmentId}/settings/billing`,
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
}
return [
{
text: t("environments.settings.billing.upgrade"),
href: isLicenseActive
? `/environments/${currentEnvironmentId}/settings/enterprise`
: "https://formbricks.com/upgrade-self-hosted-license",
},
{
text: t("common.cancel"),
onClick: () => setOpenLimitModal(false),
},
];
};
return (
<BreadcrumbItem isActive={isProjectDropdownOpen}>
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="projectDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
<span>{currentProject.name}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{isProjectDropdownOpen ? (
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
) : (
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="mt-2">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
{t("common.choose_project")}
</div>
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}
checked={proj.id === currentProject.id}
onClick={() => handleProjectChange(proj.id)}
className="cursor-pointer">
<div className="flex items-center gap-2">
<span>{proj.name}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
{isOwnerOrManager && (
<DropdownMenuCheckboxItem
onClick={handleAddProject}
className="w-full cursor-pointer justify-between">
<span>{t("common.add_new_project")}</span>
<PlusIcon className="ml-2 h-4 w-4" />
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Modals */}
{openLimitModal && (
<ProjectLimitModal
open={openLimitModal}
setOpen={setOpenLimitModal}
buttons={LimitModalButtons()}
projectLimit={organizationProjectsLimit}
/>
)}
{openCreateProjectModal && (
<CreateProjectModal
open={openCreateProjectModal}
setOpen={setOpenCreateProjectModal}
organizationId={currentOrganizationId}
isAccessControlAllowed={isAccessControlAllowed}
/>
)}
</BreadcrumbItem>
);
};

View File

@@ -37,7 +37,7 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
return redirect("./");
}
return (

View File

@@ -35,7 +35,7 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
return redirect("./");
}
return (

View File

@@ -191,9 +191,7 @@ describe("NotionIntegrationPage", () => {
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
expect(screen.getByTestId("go-back")).toHaveTextContent("./");
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
});

View File

@@ -42,12 +42,12 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
return redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<GoBackButton url={"./"} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
<NotionWrapper
enabled={enabled}

View File

@@ -27,7 +27,7 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
if (isReadOnly) {
redirect("./");
return redirect("./");
}
return (

View File

@@ -0,0 +1,77 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getOrganizationsByUserId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findMany: vi.fn(),
},
},
}));
describe("Organization", () => {
describe("getOrganizationsByUserId", () => {
test("should return organizations when found", async () => {
const mockOrganizations = [
{ id: "org1", name: "Organization 1" },
{ id: "org2", name: "Organization 2" },
];
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations as any);
const result = await getOrganizationsByUserId("user1");
expect(prisma.organization.findMany).toHaveBeenCalledWith({
where: {
memberships: {
some: {
userId: "user1",
},
},
},
select: {
id: true,
name: true,
},
});
expect(result).toEqual(mockOrganizations);
});
test("should throw ResourceNotFoundError when organizations is null", async () => {
vi.mocked(prisma.organization.findMany).mockResolvedValue(null as any);
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(
new ResourceNotFoundError("Organizations by UserId", "user1")
);
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError);
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(new DatabaseError("Database error"));
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.organization.findMany).mockRejectedValue(unknownError);
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(unknownError);
});
test("should validate inputs correctly", async () => {
await expect(getOrganizationsByUserId("")).rejects.toThrow();
});
test("should validate userId input with invalid type", async () => {
await expect(getOrganizationsByUserId(123 as any)).rejects.toThrow();
});
});
});

View File

@@ -0,0 +1,38 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZString } from "@formbricks/types/common";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getOrganizationsByUserId = reactCache(
async (userId: string): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString]);
try {
const organizations = await prisma.organization.findMany({
where: {
memberships: {
some: {
userId,
},
},
},
select: {
id: true,
name: true,
},
});
if (!organizations) {
throw new ResourceNotFoundError("Organizations by UserId", userId);
}
return organizations;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -0,0 +1,146 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership } from "@formbricks/types/memberships";
import { getProjectsByUserId } from "./project";
vi.mock("@formbricks/database", () => ({
prisma: {
project: {
findMany: vi.fn(),
},
},
}));
describe("Project", () => {
describe("getUserProjects", () => {
const mockAdminMembership: TMembership = {
role: "manager",
organizationId: "org1",
userId: "user1",
accepted: true,
};
const mockMemberMembership: TMembership = {
role: "member",
organizationId: "org1",
userId: "user1",
accepted: true,
};
test("should return projects for admin role", async () => {
const mockProjects = [
{ id: "project1", name: "Project 1" },
{ id: "project2", name: "Project 2" },
];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
select: {
id: true,
name: true,
},
});
expect(result).toEqual(mockProjects);
});
test("should return projects for member role with team restrictions", async () => {
const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectsByUserId("user1", mockMemberMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId: "user1",
},
},
},
},
},
},
select: {
id: true,
name: true,
},
});
expect(result).toEqual(mockProjects);
});
test("should return empty array when no projects found", async () => {
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getProjectsByUserId("user1", mockAdminMembership);
expect(result).toEqual([]);
});
test("should throw DatabaseError on Prisma error", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
new DatabaseError("Database error")
);
});
test("should re-throw unknown errors", async () => {
const unknownError = new Error("Unknown error");
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
});
test("should validate inputs correctly", async () => {
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
});
test("should validate membership input correctly", async () => {
const invalidMembership = {} as TMembership;
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
});
test("should handle owner role like manager", async () => {
const mockOwnerMembership: TMembership = {
role: "owner",
organizationId: "org1",
userId: "user1",
accepted: true,
};
const mockProjects = [{ id: "project1", name: "Project 1" }];
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectsByUserId("user1", mockOwnerMembership);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org1",
},
select: {
id: true,
name: true,
},
});
expect(result).toEqual(mockProjects);
});
});
});

View File

@@ -0,0 +1,51 @@
import { validateInputs } from "@/lib/utils/validate";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZString } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { TMembership, ZMembership } from "@formbricks/types/memberships";
export const getProjectsByUserId = reactCache(
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
validateInputs([userId, ZString], [orgMembership, ZMembership]);
let projectWhereClause: Prisma.ProjectWhereInput = {};
if (orgMembership.role === "member") {
projectWhereClause = {
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
};
}
try {
const projects = await prisma.project.findMany({
where: {
organizationId: orgMembership.organizationId,
...projectWhereClause,
},
select: {
id: true,
name: true,
},
});
return projects;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);

View File

@@ -0,0 +1,82 @@
// apps/web/lib/utils/version.test.ts
import { describe, expect, test } from "vitest";
import { isNewerVersion, parseVersion } from "./utils";
describe("Version utilities", () => {
describe("parseVersion", () => {
test("should parse valid semantic versions", () => {
expect(parseVersion("1.2.3")).toEqual({
major: 1,
minor: 2,
patch: 3,
});
expect(parseVersion("v2.0.0-beta.1")).toEqual({
major: 2,
minor: 0,
patch: 0,
prerelease: "beta.1",
});
});
test("should return null for invalid versions", () => {
expect(parseVersion("invalid")).toBeNull();
expect(parseVersion("1.2")).toEqual({
major: 1,
minor: 2,
patch: 0,
});
});
});
describe("isNewerVersion", () => {
test("should correctly identify newer versions", () => {
expect(isNewerVersion("1.0.0", "1.0.1")).toBe(true);
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
expect(isNewerVersion("1.0.0", "2.0.0")).toBe(true);
expect(isNewerVersion("1.0.1", "1.0.0")).toBe(false);
expect(isNewerVersion("1.0.0", "1.0.0")).toBe(false);
});
test("should handle version prefixes", () => {
expect(isNewerVersion("v1.0.0", "v1.0.1")).toBe(true);
expect(isNewerVersion("1.0.0", "v1.0.1")).toBe(true);
});
test("should handle prerelease versions", () => {
expect(isNewerVersion("1.0.0-beta.1", "1.0.0")).toBe(true);
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
});
test("should correctly compare prerelease versions with numeric parts", () => {
// Test the main issue: rc.5 vs rc.10
expect(isNewerVersion("3.17.0-rc.5", "3.17.0-rc.10")).toBe(true);
expect(isNewerVersion("3.17.0-rc.10", "3.17.0-rc.5")).toBe(false);
// Test other numeric comparisons
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta.2")).toBe(true);
expect(isNewerVersion("1.0.0-alpha.9", "1.0.0-alpha.10")).toBe(true);
expect(isNewerVersion("1.0.0-rc.99", "1.0.0-rc.100")).toBe(true);
// Test mixed alphanumeric comparisons
expect(isNewerVersion("1.0.0-alpha.1", "1.0.0-beta.1")).toBe(true);
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-rc.1")).toBe(true);
// Test versions with different number of parts
expect(isNewerVersion("1.0.0-beta", "1.0.0-beta.1")).toBe(true);
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta")).toBe(false);
});
test("should treat two-part versions as patch=0 (e.g., 3.16 == 3.16.0)", () => {
expect(isNewerVersion("3.16", "3.16.0")).toBe(false);
expect(isNewerVersion("3.16.0", "3.16")).toBe(false);
expect(isNewerVersion("3.16", "3.16.1")).toBe(true);
});
test("should ignore build metadata for precedence", () => {
expect(isNewerVersion("1.0.0+001", "1.0.0+002")).toBe(false);
expect(isNewerVersion("1.0.0", "1.0.0+exp.sha.5114f85")).toBe(false);
});
});
});

View File

@@ -0,0 +1,93 @@
export interface VersionInfo {
major: number;
minor: number;
patch: number;
prerelease?: string;
}
export const parseVersion = (version: string): VersionInfo | null => {
// Remove 'v' prefix if present
const cleanVersion = version.replace(/^v/, "");
// Regex for semantic versioning with optional patch and prerelease (no build metadata)
// Supports both 2-part (1.2) and 3-part (1.2.3) versions
const semverRegex = /^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
const match = semverRegex.exec(cleanVersion);
if (!match) {
console.warn(`Invalid version format: ${version}`);
return null;
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: match[3] ? parseInt(match[3], 10) : 0, // Default to 0 if patch is missing
prerelease: match[4],
};
};
const comparePrereleaseVersions = (current: string, latest: string): number => {
const currentParts = current.split(".");
const latestParts = latest.split(".");
const max = Math.max(currentParts.length, latestParts.length);
const isNumeric = (s: string) => /^\d+$/.test(s);
const comparePart = (a?: string, b?: string): number => {
if (!a && b) return 1; // latest has more segments → newer
if (a && !b) return -1; // current has more segments → older
if (!a && !b) return 0;
const aNum = isNumeric(a!);
const bNum = isNumeric(b!);
if (aNum && bNum) return parseInt(b!, 10) - parseInt(a!, 10);
return b!.localeCompare(a!);
};
for (let i = 0; i < max; i++) {
const diff = comparePart(currentParts[i], latestParts[i]);
if (diff !== 0) return diff;
}
return 0;
};
export const compareVersions = (current: string, latest: string): number => {
const currentVersion = parseVersion(current);
const latestVersion = parseVersion(latest);
// If either version is invalid, treat as different
if (!currentVersion || !latestVersion) {
return current === latest ? 0 : -1;
}
// Compare major.minor.patch
const majorDiff = latestVersion.major - currentVersion.major;
if (majorDiff !== 0) return majorDiff;
const minorDiff = latestVersion.minor - currentVersion.minor;
if (minorDiff !== 0) return minorDiff;
const patchDiff = latestVersion.patch - currentVersion.patch;
if (patchDiff !== 0) return patchDiff;
// Handle prerelease versions
if (!currentVersion.prerelease && !latestVersion.prerelease) {
return 0; // Both are stable, same version
}
if (!currentVersion.prerelease && latestVersion.prerelease) {
return -1; // Current is stable, latest is prerelease - current is "newer"
}
if (currentVersion.prerelease && !latestVersion.prerelease) {
return 1; // Current is prerelease, latest is stable - latest is newer
}
// Both are prerelease, compare properly
return comparePrereleaseVersions(currentVersion.prerelease!, latestVersion.prerelease!);
};
export const isNewerVersion = (current: string, latest: string): boolean => {
return compareVersions(current, latest) > 0;
};

View File

@@ -23,6 +23,10 @@ vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true,
}));
describe("EnvironmentPage", () => {
afterEach(() => {
vi.clearAllMocks();
@@ -37,7 +41,6 @@ describe("EnvironmentPage", () => {
id: mockUserId,
name: "Test User",
email: "test@example.com",
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -1,3 +1,4 @@
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
@@ -11,7 +12,11 @@ const EnvironmentPage = async (props) => {
const { isBilling } = getAccessFlags(currentUserMembership?.role);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
if (IS_FORMBRICKS_CLOUD) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
} else {
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
}
}
return redirect(`/environments/${params.environmentId}/surveys`);

View File

@@ -5,8 +5,6 @@ import {
verifyUserPassword,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { deleteFile } from "@/lib/storage/service";
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";
@@ -15,8 +13,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
import {
TUserPersonalInfoUpdateInput,
@@ -97,58 +93,6 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
)
);
const ZUpdateAvatarAction = z.object({
avatarUrl: z.string(),
});
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
const ZRemoveAvatarAction = z.object({
environmentId: ZId,
});
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
withAuditLogging(
"updated",
"user",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const oldObject = await getUser(ctx.user.id);
const imageUrl = ctx.user.imageUrl;
if (!imageUrl) {
throw new Error("Image not found");
}
const fileName = getFileNameWithIdFromUrl(imageUrl);
if (!fileName) {
throw new Error("Invalid filename");
}
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
if (!deletionResult.success) {
throw new Error("Deletion failed");
}
const result = await updateUser(ctx.user.id, { imageUrl: null });
ctx.auditLoggingCtx.userId = ctx.user.id;
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
export const resetPasswordAction = authenticatedActionClient.action(
withAuditLogging(
"passwordReset",

View File

@@ -1,104 +0,0 @@
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import * as fileUploadHooks from "@/app/lib/fileUpload";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Session } from "next-auth";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: vi.fn(),
}),
}));
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
updateAvatarAction: vi.fn(),
removeAvatarAction: vi.fn(),
}));
vi.mock("@/app/lib/fileUpload", () => ({
handleFileUpload: vi.fn(),
}));
const mockSession: Session = {
user: { id: "user-id" },
expires: "session-expires-at",
};
const environmentId = "test-env-id";
describe("EditProfileAvatarForm", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
beforeEach(() => {
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
url: "new-avatar.jpg",
error: undefined,
});
});
test("renders correctly without an existing image", () => {
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
});
test("renders correctly with an existing image", () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
});
test("handles image removal successfully", async () => {
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
});
});
test("shows error if removeAvatarAction fails", async () => {
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
render(
<EditProfileAvatarForm
session={mockSession}
environmentId={environmentId}
imageUrl="existing-avatar.jpg"
/>
);
const removeButton = screen.getByText("environments.settings.profile.remove_image");
await userEvent.click(removeButton);
await waitFor(() => {
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
"environments.settings.profile.avatar_update_failed"
);
});
});
});

View File

@@ -1,178 +0,0 @@
"use client";
import {
removeAvatarAction,
updateAvatarAction,
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
import { handleFileUpload } from "@/app/lib/fileUpload";
import { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { Session } from "next-auth";
import { useRouter } from "next/navigation";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { z } from "zod";
interface EditProfileAvatarFormProps {
session: Session;
environmentId: string;
imageUrl: string | null;
}
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { t } = useTranslate();
const fileSchema =
typeof window !== "undefined"
? z
.instanceof(FileList)
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
.refine((files) => {
const file = files[0];
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
return allowedTypes.includes(file.type);
}, t("environments.settings.profile.invalid_file_type"))
.refine((files) => {
const file = files[0];
const maxSize = 10 * 1024 * 1024;
return file.size <= maxSize;
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
: z.any();
const formSchema = z.object({
file: fileSchema,
});
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
mode: "onChange",
resolver: zodResolver(formSchema),
});
const handleUpload = async (file: File, environmentId: string) => {
setIsLoading(true);
try {
if (imageUrl) {
// If avatar image already exists, then remove it before update action
await removeAvatarAction({ environmentId });
}
const { url, error } = await handleFileUpload(file, environmentId);
if (error) {
toast.error(error);
setIsLoading(false);
return;
}
await updateAvatarAction({ avatarUrl: url });
router.refresh();
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
setIsLoading(false);
}
setIsLoading(false);
};
const handleRemove = async () => {
setIsLoading(true);
try {
await removeAvatarAction({ environmentId });
} catch (err) {
toast.error(t("environments.settings.profile.avatar_update_failed"));
} finally {
setIsLoading(false);
form.reset();
}
};
const onSubmit = async (data: FormValues) => {
const file = data.file[0];
if (file) {
await handleUpload(file, environmentId);
}
};
return (
<div>
<div className="relative h-10 w-10 overflow-hidden rounded-full">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
</div>
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
<FormField
name="file"
control={form.control}
render={({ field, fieldState }) => (
<FormItem>
<div className="flex">
<Button
type="button"
size="sm"
className="mr-2"
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
onClick={() => {
inputRef.current?.click();
}}>
{imageUrl
? t("environments.settings.profile.change_image")
: t("environments.settings.profile.upload_image")}
<input
type="file"
id="hiddenFileInput"
ref={(e) => {
field.ref(e);
inputRef.current = e;
}}
className="hidden"
accept="image/jpeg, image/png, image/webp"
onChange={(e) => {
field.onChange(e.target.files);
form.handleSubmit(onSubmit)();
}}
/>
</Button>
{imageUrl && (
<Button
type="button"
className="mr-2"
variant="destructive"
size="sm"
onClick={handleRemove}>
{t("environments.settings.profile.remove_image")}
</Button>
)}
</div>
<FormError />
</FormItem>
)}
/>
</form>
</FormProvider>
</div>
);
};

View File

@@ -49,15 +49,12 @@ describe("Loading", () => {
);
const loadingCards = screen.getAllByTestId("loading-card");
expect(loadingCards).toHaveLength(3);
expect(loadingCards).toHaveLength(2);
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
expect(loadingCards[1]).toHaveTextContent("common.avatar");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
});
});

View File

@@ -19,11 +19,6 @@ const Loading = () => {
{ classes: "h-6 w-64" },
],
},
{
title: t("common.avatar"),
description: t("environments.settings.profile.organization_identification"),
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
},
{
title: t("environments.settings.profile.delete_account"),
description: t("environments.settings.profile.confirm_delete_account"),

View File

@@ -55,11 +55,6 @@ vi.mock(
vi.mock("./components/DeleteAccount", () => ({
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
}));
vi.mock("./components/EditProfileAvatarForm", () => ({
EditProfileAvatarForm: ({ _, environmentId }) => (
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
),
}));
vi.mock("./components/EditProfileDetailsForm", () => ({
EditProfileDetailsForm: ({ user }) => (
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
@@ -73,7 +68,6 @@ const mockUser = {
id: "user-123",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
@@ -117,7 +111,6 @@ describe("ProfilePage", () => {
"AccountSettingsNavbar: env-123 profile"
);
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
expect(screen.getByTestId("delete-account")).toBeInTheDocument();

View File

@@ -12,7 +12,6 @@ import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { getTranslate } from "@/tolgee/server";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
@@ -50,17 +49,6 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
isPasswordResetEnabled={isPasswordResetEnabled}
/>
</SettingsCard>
<SettingsCard
title={t("common.avatar")}
description={t("environments.settings.profile.organization_identification")}>
{user && (
<EditProfileAvatarForm
session={session}
environmentId={environmentId}
imageUrl={user.imageUrl}
/>
)}
</SettingsCard>
{user.identityProvider === "email" && (
<SettingsCard
title={t("common.security")}

View File

@@ -34,26 +34,12 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/general"),
hidden: false,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading,
current: pathname?.includes("/billing"),
},
{
id: "teams",
label: t("common.teams"),
href: `/environments/${environmentId}/settings/teams`,
current: pathname?.includes("/teams"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
{
id: "api-keys",
label: t("common.api_keys"),
@@ -61,6 +47,20 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
{
id: "billing",
label: t("common.billing"),
href: `/environments/${environmentId}/settings/billing`,
hidden: !isFormbricksCloud || loading,
current: pathname?.includes("/billing"),
},
{
id: "enterprise",
label: t("common.enterprise_license"),
href: `/environments/${environmentId}/settings/enterprise`,
hidden: isFormbricksCloud || isPricingDisabled,
current: pathname?.includes("/enterprise"),
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;

View File

@@ -126,7 +126,6 @@ const mockUser = {
createdAt: new Date(),
updatedAt: new Date(),
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
notificationSettings: { alert: {} },

View File

@@ -14,7 +14,7 @@ const Page = async (props) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
notFound();
return notFound();
}
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
@@ -22,7 +22,7 @@ const Page = async (props) => {
const isPricingDisabled = isMember;
if (isPricingDisabled) {
notFound();
return notFound();
}
const { active: isEnterpriseEdition } = await getEnterpriseLicense();

View File

@@ -128,7 +128,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -146,7 +145,7 @@ const mockLocale: TUserLocale = "en-US";
const mockSetSelectedResponseId = vi.fn();
const mockUpdateResponse = vi.fn();
const mockDeleteResponses = vi.fn();
const mockUpdateResponseList = vi.fn();
const mockSetOpen = vi.fn();
const defaultProps = {
@@ -158,7 +157,7 @@ const defaultProps = {
user: mockUser,
environmentTags: mockEnvironmentTags,
updateResponse: mockUpdateResponse,
deleteResponses: mockDeleteResponses,
updateResponseList: mockUpdateResponseList,
isReadOnly: false,
open: true,
setOpen: mockSetOpen,

View File

@@ -18,7 +18,7 @@ interface ResponseCardModalProps {
user?: TUser;
environmentTags: TTag[];
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
deleteResponses: (responseIds: string[]) => void;
updateResponseList: (responseIds: string[]) => void;
isReadOnly: boolean;
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -34,7 +34,7 @@ export const ResponseCardModal = ({
user,
environmentTags,
updateResponse,
deleteResponses,
updateResponseList,
isReadOnly,
open,
setOpen,
@@ -86,7 +86,7 @@ export const ResponseCardModal = ({
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
deleteResponses={deleteResponses}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
locale={locale}
/>

View File

@@ -145,7 +145,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -177,7 +176,7 @@ const defaultProps = {
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: true,
deleteResponses: vi.fn(),
updateResponseList: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: mockLocale,
@@ -265,7 +264,7 @@ describe("ResponseDataView", () => {
expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment);
expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage);
expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true);
expect(responseTableMock.mock.calls[0][0].deleteResponses).toBe(defaultProps.deleteResponses);
expect(responseTableMock.mock.calls[0][0].updateResponseList).toBe(defaultProps.updateResponseList);
expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse);
expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false);
expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale);

View File

@@ -4,24 +4,27 @@ import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/
import { TFnType, useTranslate } from "@tolgee/react";
import React from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseDataValue, TResponseTableData } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseDataValue, TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseDataViewProps {
survey: TSurvey;
responses: TResponse[];
responses: TResponseWithQuotas[];
user?: TUser;
environment: TEnvironment;
environmentTags: TTag[];
isReadOnly: boolean;
fetchNextPage: () => void;
hasMore: boolean;
deleteResponses: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
updateResponseList: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
isFetchingFirstPage: boolean;
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
// Export for testing
@@ -47,7 +50,7 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
};
// Export for testing
export const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
let responseData: Record<string, any> = {};
survey.questions.forEach((question) => {
@@ -78,7 +81,7 @@ export const extractResponseData = (response: TResponse, survey: TSurvey): Recor
// Export for testing
export const mapResponsesToTableData = (
responses: TResponse[],
responses: TResponseWithQuotas[],
survey: TSurvey,
t: TFnType
): TResponseTableData[] => {
@@ -101,6 +104,7 @@ export const mapResponsesToTableData = (
person: response.contact,
contactAttributes: response.contactAttributes,
meta: response.meta,
quotas: response.quotas?.map((quota) => quota.name),
}));
};
@@ -113,10 +117,12 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
isReadOnly,
fetchNextPage,
hasMore,
deleteResponses,
updateResponseList,
updateResponse,
isFetchingFirstPage,
locale,
isQuotasAllowed,
quotas,
}) => {
const { t } = useTranslate();
const data = mapResponsesToTableData(responses, survey, t);
@@ -133,10 +139,12 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
environment={environment}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
deleteResponses={deleteResponses}
updateResponseList={updateResponseList}
updateResponse={updateResponse}
isFetchingFirstPage={isFetchingFirstPage}
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</div>
);

View File

@@ -187,7 +187,7 @@ describe("ResponsePage", () => {
).mock.calls[0][0];
act(() => {
responseDataViewProps.deleteResponses(["response1"]);
responseDataViewProps.updateResponseList(["response1"]);
});
// Check if ResponseDataView is re-rendered with updated responses

View File

@@ -9,7 +9,8 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -23,6 +24,8 @@ interface ResponsePageProps {
responsesPerPage: number;
locale: TUserLocale;
isReadOnly: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
export const ResponsePage = ({
@@ -34,8 +37,10 @@ export const ResponsePage = ({
responsesPerPage,
locale,
isReadOnly,
isQuotasAllowed,
quotas,
}: ResponsePageProps) => {
const [responses, setResponses] = useState<TResponse[]>([]);
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
const [page, setPage] = useState<number>(1);
const [hasMore, setHasMore] = useState<boolean>(true);
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
@@ -53,7 +58,7 @@ export const ResponsePage = ({
const fetchNextPage = useCallback(async () => {
const newPage = page + 1;
let newResponses: TResponse[] = [];
let newResponses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
surveyId,
@@ -70,14 +75,12 @@ export const ResponsePage = ({
setPage(newPage);
}, [filters, page, responses, responsesPerPage, surveyId]);
const deleteResponses = (responseIds: string[]) => {
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
const updateResponseList = (responseIds: string[]) => {
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
};
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
if (responses) {
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
}
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
};
const surveyMemoized = useMemo(() => {
@@ -94,7 +97,7 @@ export const ResponsePage = ({
const fetchInitialResponses = async () => {
try {
setFetchingFirstPage(true);
let responses: TResponse[] = [];
let responses: TResponseWithQuotas[] = [];
const getResponsesActionResponse = await getResponsesAction({
surveyId,
@@ -136,10 +139,12 @@ export const ResponsePage = ({
isReadOnly={isReadOnly}
fetchNextPage={fetchNextPage}
hasMore={hasMore}
deleteResponses={deleteResponses}
updateResponseList={updateResponseList}
updateResponse={updateResponse}
isFetchingFirstPage={isFetchingFirstPage}
locale={locale}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</>
);

View File

@@ -32,7 +32,8 @@ import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurveyQuota } from "@formbricks/types/quota";
import { TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
@@ -40,17 +41,19 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps {
data: TResponseTableData[];
survey: TSurvey;
responses: TResponse[] | null;
responses: TResponseWithQuotas[] | null;
environment: TEnvironment;
user?: TUser;
environmentTags: TTag[];
isReadOnly: boolean;
fetchNextPage: () => void;
hasMore: boolean;
deleteResponses: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
updateResponseList: (responseIds: string[]) => void;
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
isFetchingFirstPage: boolean;
locale: TUserLocale;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
}
export const ResponseTable = ({
@@ -63,10 +66,12 @@ export const ResponseTable = ({
isReadOnly,
fetchNextPage,
hasMore,
deleteResponses,
updateResponseList,
updateResponse,
isFetchingFirstPage,
locale,
isQuotasAllowed,
quotas,
}: ResponseTableProps) => {
const { t } = useTranslate();
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
@@ -78,8 +83,9 @@ export const ResponseTable = ({
const [columnOrder, setColumnOrder] = useState<string[]>([]);
const [parent] = useAutoAnimate();
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
// Generate columns
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t);
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
// Save settings to localStorage when they change
useEffect(() => {
@@ -178,8 +184,8 @@ export const ResponseTable = ({
}
};
const deleteResponse = async (responseId: string) => {
await deleteResponseAction({ responseId });
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
};
// Handle downloading selected responses
@@ -221,10 +227,11 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRowsAction={deleteResponses}
updateRowList={updateResponseList}
type="response"
deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
isQuotasAllowed={isQuotasAllowed}
/>
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto">
@@ -299,7 +306,7 @@ export const ResponseTable = ({
environmentTags={environmentTags}
isReadOnly={isReadOnly}
updateResponse={updateResponse}
deleteResponses={deleteResponses}
updateResponseList={updateResponseList}
setSelectedResponseId={setSelectedResponseId}
selectedResponseId={selectedResponseId}
open={selectedResponse !== null}

View File

@@ -1,5 +1,4 @@
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
import { processResponseData } from "@/lib/responses";
import { getContactIdentifier } from "@/lib/utils/contact";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
@@ -31,10 +30,6 @@ vi.mock("@/lib/i18n/utils", () => ({
getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default),
}));
vi.mock("@/lib/responses", () => ({
processResponseData: vi.fn((data) => (Array.isArray(data) ? data.join(", ") : String(data))),
}));
vi.mock("@/lib/utils/contact", () => ({
getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"),
}));
@@ -139,33 +134,39 @@ const mockSurvey = {
questions: [
{
id: "q1open",
type: TSurveyQuestionTypeEnum.OpenText,
type: "openText",
headline: { default: "Open Text Question" },
required: true,
} as unknown as TSurveyQuestion,
{
id: "q2matrix",
type: TSurveyQuestionTypeEnum.Matrix,
type: "matrix",
headline: { default: "Matrix Question" },
rows: [{ default: "Row1" }, { default: "Row2" }],
columns: [{ default: "Col1" }, { default: "Col2" }],
rows: [
{ id: "row-1", label: { default: "Row1" } },
{ id: "row-2", label: { default: "Row2" } },
],
columns: [
{ id: "col-1", label: { default: "Col1" } },
{ id: "col-2", label: { default: "Col2" } },
],
required: false,
} as unknown as TSurveyQuestion,
{
id: "q3address",
type: TSurveyQuestionTypeEnum.Address,
type: "address",
headline: { default: "Address Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q4contact",
type: TSurveyQuestionTypeEnum.ContactInfo,
type: "contactInfo",
headline: { default: "Contact Info Question" },
required: false,
} as unknown as TSurveyQuestion,
{
id: "q5single",
type: TSurveyQuestionTypeEnum.MultipleChoiceSingle,
type: "multipleChoiceSingle",
headline: { default: "Single Choice Question" },
required: false,
choices: [
@@ -176,7 +177,7 @@ const mockSurvey = {
} as unknown as TSurveyQuestion,
{
id: "q6multi",
type: TSurveyQuestionTypeEnum.MultipleChoiceMulti,
type: "multipleChoiceMulti",
headline: { default: "Multi Choice Question" },
required: false,
choices: [
@@ -245,44 +246,44 @@ describe("generateResponseTableColumns", () => {
});
test("should include selection column when not read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any, false);
expect(columns[0].id).toBe("select");
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
});
test("should not include selection column when read-only", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
expect(columns[0].id).not.toBe("select");
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
});
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any, false);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
});
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
});
test("should generate columns for variables", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const var1Col = columns.find((col) => (col as any).accessorKey === "var1");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const var1Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
expect(var1Col).toBeDefined();
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(var1Cell.props.children).toBe("Segment A");
const var2Col = columns.find((col) => (col as any).accessorKey === "var2");
const var2Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
expect(var2Col).toBeDefined();
const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(var2Cell.props.children).toBe(100);
});
test("should generate columns for hidden fields if fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const hf1Col = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
expect(hf1Col).toBeDefined();
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
expect(hf1Cell.props.children).toBe("Hidden Field 1 Value");
@@ -290,7 +291,7 @@ describe("generateResponseTableColumns", () => {
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any, false);
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
expect(hf1Col).toBeUndefined();
});
@@ -315,7 +316,7 @@ describe("ResponseTableColumns", () => {
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
// Assert
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
@@ -343,7 +344,7 @@ describe("ResponseTableColumns", () => {
const isReadOnly = false;
// Act
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
// Assert
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
@@ -357,7 +358,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("dateColumn renders with formatted date", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
expect(dateColumn).toBeDefined();
@@ -375,7 +376,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("personColumn renders anonymous when person is null", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
@@ -398,7 +399,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("personColumn renders person identifier when person exists", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
expect(personColumn).toBeDefined();
@@ -419,7 +420,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("tagsColumn returns undefined when tags is not an array", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
expect(tagsColumn).toBeDefined();
@@ -434,10 +435,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("variableColumns render variable values correctly", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find the variable column for var1
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
const var1Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
expect(var1Column).toBeDefined();
// Test the header
@@ -454,7 +455,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
expect(cellResult?.props.children).toBe("Test Value");
// Test with a number variable
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
const var2Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
expect(var2Column).toBeDefined();
const mockRowNumber = {
@@ -466,10 +467,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
});
test("hiddenFieldColumns render when fieldIds exist", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find the hidden field column
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
expect(hfColumn).toBeDefined();
// Test the header
@@ -493,10 +494,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
hiddenFields: { enabled: true }, // no fieldIds
};
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any, false);
// Check that no hidden field columns were created
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
const hfColumn = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
expect(hfColumn).toBeUndefined();
});
});
@@ -511,32 +512,32 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("generates two columns for multipleChoiceSingle questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q5single");
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("generates two columns for multipleChoiceMulti questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Should have main response column
const mainColumn = columns.find((col) => (col as any).accessorKey === "q6multi");
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
expect(mainColumn).toBeDefined();
// Should have option IDs column
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
expect(optionIdsColumn).toBeDefined();
});
test("multipleChoiceSingle main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const mockRow = {
original: {
@@ -551,8 +552,8 @@ describe("ResponseTableColumns - Multiple Choice Questions", () => {
});
test("multipleChoiceMulti main column renders RenderResponse component", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
const mockRow = {
original: {
@@ -577,8 +578,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
const mockRow = {
original: {
@@ -597,8 +600,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
);
const mockRow = {
original: {
@@ -617,8 +622,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column renders IdBadge components for choice IDs", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
);
const mockRow = {
original: {
@@ -639,8 +646,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column returns null for non-string/array response values", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
const mockRow = {
original: {
@@ -656,8 +665,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column returns null when no choice IDs found", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
const mockRow = {
original: {
@@ -675,8 +686,10 @@ describe("ResponseTableColumns - Choice ID Columns", () => {
});
test("option IDs column handles missing language gracefully", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
const mockRow = {
original: {
@@ -705,9 +718,11 @@ describe("ResponseTableColumns - Helper Functions", () => {
});
test("question headers are properly created for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const optionIdsColumn: any = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const optionIdsColumn: any = columns.find(
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
);
// Test main column header
const mainHeader = mainColumn?.header?.();
@@ -721,9 +736,9 @@ describe("ResponseTableColumns - Helper Functions", () => {
});
test("question headers include proper icons for multiple choice questions", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "q6multi");
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
// Headers should be functions that return JSX
expect(typeof singleChoiceColumn?.header).toBe("function");
@@ -745,13 +760,13 @@ describe("ResponseTableColumns - Integration Tests", () => {
});
test("multiple choice questions work end-to-end with real data", () => {
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
// Find all multiple choice related columns
const singleMainCol = columns.find((col) => (col as any).accessorKey === "q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "q6multioptionIds");
const singleMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
const multiMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
expect(singleMainCol).toBeDefined();
expect(singleIdsCol).toBeDefined();

View File

@@ -34,6 +34,8 @@ const getQuestionColumnsData = (
t: TFnType
): ColumnDef<TResponseTableData>[] => {
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
// Helper function to create consistent column headers
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
@@ -74,7 +76,7 @@ const getQuestionColumnsData = (
case "matrix":
return question.rows.map((matrixRow) => {
return {
accessorKey: matrixRow.default,
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
header: () => {
return (
<div className="flex items-center justify-between">
@@ -83,14 +85,14 @@ const getQuestionColumnsData = (
<span className="truncate">
{getLocalizedValue(question.headline, "default") +
" - " +
getLocalizedValue(matrixRow, "default")}
getLocalizedValue(matrixRow.label, "default")}
</span>
</div>
</div>
);
},
cell: ({ row }) => {
const responseValue = row.original.responseData[matrixRow.default];
const responseValue = row.original.responseData[matrixRow.label.default];
if (typeof responseValue === "string") {
return <p className="text-slate-900">{responseValue}</p>;
}
@@ -99,10 +101,9 @@ const getQuestionColumnsData = (
});
case "address":
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
return addressFields.map((addressField) => {
return {
accessorKey: addressField,
accessorKey: "QUESTION_" + question.id + "_" + addressField,
header: () => {
return (
<div className="flex items-center justify-between">
@@ -123,10 +124,9 @@ const getQuestionColumnsData = (
});
case "contactInfo":
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
return contactInfoFields.map((contactInfoField) => {
return {
accessorKey: contactInfoField,
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
header: () => {
return (
<div className="flex items-center justify-between">
@@ -153,7 +153,7 @@ const getQuestionColumnsData = (
const questionHeadline = getQuestionHeadline(question, survey);
return [
{
accessorKey: question.id,
accessorKey: "QUESTION_" + question.id,
header: createQuestionHeader(question.type, questionHeadline),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
@@ -171,7 +171,7 @@ const getQuestionColumnsData = (
},
},
{
accessorKey: question.id + "optionIds",
accessorKey: "QUESTION_" + question.id + "optionIds",
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
cell: ({ row }) => {
const responseValue = row.original.responseData[question.id];
@@ -193,7 +193,7 @@ const getQuestionColumnsData = (
default:
return [
{
accessorKey: question.id,
accessorKey: "QUESTION_" + question.id,
header: () => (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 overflow-hidden">
@@ -233,7 +233,7 @@ const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] =>
const IconComponent = COLUMNS_ICON_MAP[label];
metadataColumns.push({
accessorKey: label,
accessorKey: "METADATA_" + label,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
@@ -257,7 +257,8 @@ export const generateResponseTableColumns = (
survey: TSurvey,
isExpanded: boolean,
isReadOnly: boolean,
t: TFnType
t: TFnType,
showQuotasColumn: boolean
): ColumnDef<TResponseTableData>[] => {
const questionColumns = survey.questions.flatMap((question) =>
getQuestionColumnsData(question, survey, isExpanded, t)
@@ -283,12 +284,13 @@ export const generateResponseTableColumns = (
<TooltipTrigger>
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
</TooltipTrigger>
<TooltipContent side="bottom" className="font-normal">
{t("environments.surveys.responses.how_to_identify_users")}
<TooltipContent side="bottom" className="space-x-1 font-normal">
<span>{t("environments.surveys.responses.how_to_identify_users")}</span>
<Link
className="underline underline-offset-2 hover:text-slate-900"
href="https://formbricks.com/docs/app-surveys/user-identification"
target="_blank">
target="_blank"
rel="noopener noreferrer">
{t("common.app_survey")}
</Link>
</TooltipContent>
@@ -305,10 +307,21 @@ export const generateResponseTableColumns = (
},
};
const quotasColumn: ColumnDef<TResponseTableData> = {
accessorKey: "quota",
header: t("common.quota"),
cell: ({ row }) => {
const quotas = row.original.quotas;
const items = quotas?.map((quota) => ({ value: quota })) ?? [];
return <ResponseBadges items={items} showId={false} />;
},
size: 200,
};
const statusColumn: ColumnDef<TResponseTableData> = {
accessorKey: "status",
size: 200,
header: t("common.status"),
header: () => <div className="gap-x-1.5">{t("common.status")}</div>,
cell: ({ row }) => {
const status = row.original.status;
return <ResponseBadges items={[{ value: status }]} showId={false} />;
@@ -317,7 +330,7 @@ export const generateResponseTableColumns = (
const tagsColumn: ColumnDef<TResponseTableData> = {
accessorKey: "tags",
header: t("common.tags"),
header: () => <div className="gap-x-1.5">{t("common.tags")}</div>,
cell: ({ row }) => {
const tags = row.original.tags;
if (Array.isArray(tags)) {
@@ -336,7 +349,7 @@ export const generateResponseTableColumns = (
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
return {
accessorKey: variable.id,
accessorKey: "VARIABLE_" + variable.id,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
@@ -355,7 +368,7 @@ export const generateResponseTableColumns = (
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
return {
accessorKey: hiddenFieldId,
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
header: () => (
<div className="flex items-center space-x-2 overflow-hidden">
<span className="h-4 w-4">
@@ -392,6 +405,7 @@ export const generateResponseTableColumns = (
const baseColumns = [
personColumn,
dateColumn,
...(showQuotasColumn ? [quotasColumn] : []),
statusColumn,
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
...questionColumns,

View File

@@ -10,8 +10,11 @@ import { getSurvey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
@@ -102,6 +105,19 @@ vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
}));
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
getIsContactsEnabled: vi.fn(),
}));
vi.mock("@/modules/ui/components/page-header", () => ({
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
<div data-testid="page-header">
@@ -194,6 +210,9 @@ describe("ResponsesPage", () => {
test("renders correctly with all data", async () => {
const props = { params: mockParams };
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
const jsx = await Page(props);
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);

View File

@@ -10,8 +10,11 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { getUser } from "@/lib/user/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -46,6 +49,19 @@ const Page = async (props) => {
const locale = await findMatchingLocale();
const publicDomain = getPublicDomain();
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
return (
<PageContentWrapper>
<PageHeader
@@ -75,6 +91,8 @@ const Page = async (props) => {
responsesPerPage={RESPONSES_PER_PAGE}
locale={locale}
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
/>
</PageContentWrapper>
);

View File

@@ -28,6 +28,8 @@ const baseSummary = {
startsPercentage: 75,
totalResponses: 4,
ttcAverage: 65000,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
};
describe("SummaryMetadata", () => {
@@ -38,25 +40,26 @@ describe("SummaryMetadata", () => {
test("renders loading skeletons when isLoading=true", () => {
const { container } = render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab={undefined}
setTab={() => {}}
surveySummary={baseSummary}
isLoading={true}
isQuotasAllowed={true}
/>
);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(6);
});
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
tab={"dropOffs"}
setTab={() => {}}
surveySummary={baseSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
};
@@ -83,10 +86,11 @@ describe("SummaryMetadata", () => {
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={smallSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getByText("5.00s")).toBeInTheDocument();
@@ -95,13 +99,13 @@ describe("SummaryMetadata", () => {
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
const Wrapper = () => {
const [show, setShow] = useState(false);
return (
<SummaryMetadata
showDropOffs={show}
setShowDropOffs={setShow}
tab="dropOffs"
setTab={() => {}}
surveySummary={zeroSummary}
isLoading={false}
isQuotasAllowed={false}
/>
);
};
@@ -119,10 +123,11 @@ describe("SummaryMetadata", () => {
const dispZero = { ...baseSummary, displayCount: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={dispZero}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);
@@ -132,10 +137,11 @@ describe("SummaryMetadata", () => {
const totZero = { ...baseSummary, totalResponses: 0 };
render(
<SummaryMetadata
showDropOffs={false}
setShowDropOffs={() => {}}
tab="dropOffs"
setTab={() => {}}
surveySummary={totZero}
isLoading={false}
isQuotasAllowed={false}
/>
);
expect(screen.getAllByText("-")).toHaveLength(1);

View File

@@ -1,44 +1,19 @@
"use client";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { InteractiveCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/interactive-card";
import { StatCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/stat-card";
import { cn } from "@/modules/ui/lib/utils";
import { useTranslate } from "@tolgee/react";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { TSurveySummary } from "@formbricks/types/surveys/types";
interface SummaryMetadataProps {
setShowDropOffs: React.Dispatch<React.SetStateAction<boolean>>;
showDropOffs: boolean;
surveySummary: TSurveySummary["meta"];
isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
const StatCard = ({ label, percentage, value, tooltipText, isLoading }) => {
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" && !isNaN(percentage) && !isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{percentage}%</span>
)}
</p>
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const formatTime = (ttc) => {
const seconds = ttc / 1000;
let formattedValue;
@@ -55,10 +30,11 @@ const formatTime = (ttc) => {
};
export const SummaryMetadata = ({
setShowDropOffs,
showDropOffs,
surveySummary,
isLoading,
tab,
setTab,
isQuotasAllowed,
}: SummaryMetadataProps) => {
const {
completedPercentage,
@@ -69,13 +45,24 @@ export const SummaryMetadata = ({
startsPercentage,
totalResponses,
ttcAverage,
quotasCompleted,
quotasCompletedPercentage,
} = surveySummary;
const { t } = useTranslate();
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
return (
<div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
<div
className={cn(
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && "2xl:grid-cols-6"
)}>
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
@@ -98,41 +85,17 @@ export const SummaryMetadata = ({
isLoading={isLoading}
/>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
<span className="text-sm text-slate-600">
{t("environments.surveys.summary.drop_offs")}
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">{`${Math.round(dropOffPercentage)}%`}</span>
)}
</span>
<div className="flex w-full items-end justify-between">
<span className="text-2xl font-bold text-slate-800">
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
displayCountValue
)}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{showDropOffs ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</div>
)}
</div>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t("environments.surveys.summary.drop_offs_tooltip")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<InteractiveCard
key="dropOffs"
tab="dropOffs"
label={t("environments.surveys.summary.drop_offs")}
percentage={dropOffPercentage}
value={dropoffCountValue}
tooltipText={t("environments.surveys.summary.drop_offs_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("dropOffs")}
isActive={tab === "dropOffs"}
/>
<StatCard
label={t("environments.surveys.summary.time_to_complete")}
@@ -141,6 +104,20 @@ export const SummaryMetadata = ({
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
isLoading={isLoading}
/>
{isQuotasAllowed && (
<InteractiveCard
key="quotas"
tab="quotas"
label={t("environments.surveys.summary.quotas_completed")}
percentage={quotasCompletedPercentage}
value={quotasCompleted}
tooltipText={t("environments.surveys.summary.quotas_completed_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("quotas")}
isActive={tab === "quotas"}
/>
)}
</div>
</div>
);

View File

@@ -20,6 +20,8 @@ vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/
startsPercentage: 100,
totalResponses: 50,
ttcAverage: 120,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
dropOff: [
{
@@ -67,10 +69,10 @@ vi.mock(
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata",
() => ({
SummaryMetadata: ({ showDropOffs, setShowDropOffs, isLoading }: any) => (
SummaryMetadata: ({ tab, setTab, isLoading }: any) => (
<div data-testid="summary-metadata">
<span>Is Loading: {isLoading ? "true" : "false"}</span>
<button onClick={() => setShowDropOffs(!showDropOffs)}>Toggle Dropoffs</button>
<button onClick={() => setTab(tab === "dropOffs" ? "quotas" : "dropOffs")}>Toggle Dropoffs</button>
</div>
),
})
@@ -166,7 +168,7 @@ describe("SummaryPage", () => {
expect(screen.queryByTestId("summary-drop-offs")).not.toBeInTheDocument();
// Toggle drop-offs
await user.click(screen.getByText("Toggle Dropoffs"));
await user.click(screen.getByRole("button", { name: "Toggle Dropoffs" }));
// Drop-offs should now be visible
expect(screen.getByTestId("summary-drop-offs")).toBeInTheDocument();

View File

@@ -7,6 +7,7 @@ import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/survey
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
@@ -25,8 +26,11 @@ const defaultSurveySummary: TSurveySummary = {
startsPercentage: 0,
totalResponses: 0,
ttcAverage: 0,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
dropOff: [],
quotas: [],
summary: [],
};
@@ -36,6 +40,7 @@ interface SummaryPageProps {
surveyId: string;
locale: TUserLocale;
initialSurveySummary?: TSurveySummary;
isQuotasAllowed: boolean;
}
export const SummaryPage = ({
@@ -44,13 +49,15 @@ export const SummaryPage = ({
surveyId,
locale,
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [showDropOffs, setShowDropOffs] = useState<boolean>(false);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
@@ -108,11 +115,13 @@ export const SummaryPage = ({
<>
<SummaryMetadata
surveySummary={surveySummary.meta}
showDropOffs={showDropOffs}
setShowDropOffs={setShowDropOffs}
isLoading={isLoading}
tab={tab}
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{showDropOffs && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">
<CustomFilter survey={surveyMemoized} />
</div>

View File

@@ -170,7 +170,7 @@ vi.mock("@/modules/ui/components/confirmation-modal", () => ({
open,
setOpen,
title,
text,
body,
buttonText,
onConfirm,
buttonVariant,
@@ -182,7 +182,7 @@ vi.mock("@/modules/ui/components/confirmation-modal", () => ({
data-loading={buttonLoading}
data-variant={buttonVariant}>
<div data-testid="modal-title">{title}</div>
<div data-testid="modal-text">{text}</div>
<div data-testid="modal-text">{body}</div>
<button type="button" onClick={onConfirm} data-testid="confirm-button">
{buttonText}
</button>
@@ -291,7 +291,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -235,7 +235,7 @@ export const SurveyAnalysisCTA = ({
open={isResetModalOpen}
setOpen={setIsResetModalOpen}
title={t("environments.surveys.summary.delete_all_existing_responses_and_displays")}
text={t("environments.surveys.summary.reset_survey_warning")}
body={t("environments.surveys.summary.reset_survey_warning")}
buttonText={t("environments.surveys.summary.reset_survey")}
onConfirm={handleResetSurvey}
buttonVariant="destructive"

View File

@@ -0,0 +1,83 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { BaseCard } from "./base-card";
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipProvider: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-provider">{children}</div>
),
Tooltip: ({ children }: { children: React.ReactNode }) => <div data-testid="tooltip">{children}</div>,
TooltipTrigger: ({
children,
onClick,
"data-testid": dataTestId,
}: {
children: React.ReactNode;
onClick?: () => void;
"data-testid"?: string;
}) => (
<div data-testid="tooltip-trigger" onClick={onClick} data-testid-value={dataTestId}>
{children}
</div>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="tooltip-content">{children}</div>
),
}));
describe("BaseCard", () => {
afterEach(() => {
cleanup();
});
test("renders basic card with label and children", () => {
render(
<BaseCard label="Test Label" tooltipText="Test tooltip">
<div>Test Content</div>
</BaseCard>
);
expect(screen.getByText("Test Label")).toBeInTheDocument();
expect(screen.getByText("Test Content")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
expect(screen.getByTestId("tooltip")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-trigger")).toBeInTheDocument();
expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
});
test("displays percentage when provided as valid number", () => {
render(
<BaseCard label="Test Label" percentage={75}>
<div>Test Content</div>
</BaseCard>
);
expect(screen.getByText("75%")).toBeInTheDocument();
});
test("does not display percentage when loading", () => {
render(
<BaseCard label="Test Label" percentage={75} isLoading={true}>
<div>Test Content</div>
</BaseCard>
);
expect(screen.queryByText("75%")).not.toBeInTheDocument();
});
test("calls onClick when card is clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<BaseCard label="Test Label" onClick={handleClick}>
<div>Test Content</div>
</BaseCard>
);
await user.click(screen.getByTestId("tooltip-trigger"));
expect(handleClick).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,65 @@
"use client";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { cn } from "@/modules/ui/lib/utils";
import { ReactNode } from "react";
interface BaseCardProps {
label: ReactNode;
percentage?: number | null;
tooltipText?: ReactNode;
isLoading?: boolean;
onClick?: () => void;
children: ReactNode;
className?: string;
testId?: string;
id?: string;
}
export const BaseCard = ({
label,
percentage = null,
tooltipText,
isLoading = false,
onClick,
children,
className,
testId,
id,
}: BaseCardProps) => {
const isClickable = !!onClick;
return (
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger onClick={onClick} data-testid={testId}>
<div
className={cn(
"flex h-full flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm",
isClickable ? "cursor-pointer" : "cursor-default",
className
)}
id={id}>
<p className="flex items-center gap-1 text-sm text-slate-600">
{label}
{typeof percentage === "number" &&
!isNaN(percentage) &&
Number.isFinite(percentage) &&
!isLoading && (
<span className="ml-1 rounded-xl bg-slate-100 px-2 py-1 text-xs">
{Math.round(percentage)}%
</span>
)}
</p>
{children}
</div>
</TooltipTrigger>
{tooltipText && (
<TooltipContent>
<p>{tooltipText}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
);
};

View File

@@ -0,0 +1,127 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { InteractiveCard } from "./interactive-card";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
() => ({
BaseCard: ({
label,
percentage,
tooltipText,
isLoading,
onClick,
testId,
id,
children,
}: {
label: React.ReactNode;
percentage?: number | null;
tooltipText?: React.ReactNode;
isLoading?: boolean;
onClick?: () => void;
testId?: string;
id?: string;
children: React.ReactNode;
}) => (
<div data-testid="base-card" onClick={onClick}>
<div data-testid="base-card-label">{label}</div>
{percentage !== null && percentage !== undefined && (
<div data-testid="base-card-percentage">{percentage}%</div>
)}
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
<div data-testid="base-card-testid">{testId}</div>
<div data-testid="base-card-id">{id}</div>
<div data-testid="base-card-children">{children}</div>
</div>
),
})
);
vi.mock("lucide-react", () => ({
ChevronDownIcon: ({ className }: { className: string }) => (
<div data-testid="chevron-down-icon" className={className}>
ChevronDown
</div>
),
ChevronUpIcon: ({ className }: { className: string }) => (
<div data-testid="chevron-up-icon" className={className}>
ChevronUp
</div>
),
}));
describe("InteractiveCard", () => {
afterEach(() => {
cleanup();
});
const defaultProps = {
tab: "dropOffs" as const,
label: "Test Label",
percentage: 75,
value: "Test Value",
tooltipText: "Test tooltip",
isLoading: false,
onClick: vi.fn(),
isActive: false,
};
test("renders with basic props", () => {
render(<InteractiveCard {...defaultProps} />);
expect(screen.getByTestId("base-card")).toBeInTheDocument();
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
expect(screen.getByTestId("base-card-tooltip")).toHaveTextContent("Test tooltip");
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
});
test("generates correct testId and id based on tab", () => {
render(<InteractiveCard {...defaultProps} tab="quotas" />);
expect(screen.getByTestId("base-card-testid")).toHaveTextContent("quotas-toggle");
expect(screen.getByTestId("base-card-id")).toHaveTextContent("quotas-toggle");
});
test("calls onClick when clicked", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(<InteractiveCard {...defaultProps} onClick={handleClick} />);
await user.click(screen.getByTestId("base-card"));
expect(handleClick).toHaveBeenCalledOnce();
});
test("renders loading state with skeleton", () => {
render(<InteractiveCard {...defaultProps} isLoading={true} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
const skeleton = screen.getByTestId("base-card-children").querySelector(".animate-pulse");
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
expect(screen.queryByTestId("chevron-up-icon")).not.toBeInTheDocument();
});
test("shows chevron up icon when active", () => {
render(<InteractiveCard {...defaultProps} isActive={true} />);
expect(screen.getByTestId("chevron-up-icon")).toBeInTheDocument();
expect(screen.getByTestId("chevron-up-icon")).toHaveClass("h-4", "w-4");
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
});
test("handles zero percentage", () => {
render(<InteractiveCard {...defaultProps} percentage={0} />);
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("0%");
});
});

View File

@@ -0,0 +1,48 @@
"use client";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas";
label: string;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;
onClick: () => void;
isActive: boolean;
}
export const InteractiveCard = ({
tab,
label,
percentage,
value,
tooltipText,
isLoading,
onClick,
isActive,
}: InteractiveCardProps) => {
return (
<BaseCard
label={label}
percentage={percentage}
tooltipText={tooltipText}
isLoading={isLoading}
onClick={onClick}
testId={`${tab}-toggle`}
id={`${tab}-toggle`}>
<div className="flex w-full items-end justify-between">
<span className="text-2xl font-bold text-slate-800">
{isLoading ? <div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div> : value}
</span>
{!isLoading && (
<div className="flex h-6 w-6 items-center justify-center rounded-md border border-slate-200 bg-slate-50 hover:bg-slate-100">
{isActive ? <ChevronUpIcon className="h-4 w-4" /> : <ChevronDownIcon className="h-4 w-4" />}
</div>
)}
</div>
</BaseCard>
);
};

View File

@@ -250,7 +250,6 @@ const mockUser: TUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "https://example.com/avatar.jpg",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),

View File

@@ -29,7 +29,7 @@ import {
SquareStack,
UserIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUser } from "@formbricks/types/user";
@@ -77,6 +77,7 @@ export const ShareSurveyModal = ({
description: string;
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(
() => [
{
@@ -111,6 +112,7 @@ export const ShareSurveyModal = ({
isContactsEnabled,
isFormbricksCloud,
},
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.WEBSITE_EMBED,
@@ -121,6 +123,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.embed_on_website.description"),
componentType: WebsiteEmbedTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.EMAIL,
@@ -131,6 +134,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.send_email.description"),
componentType: EmailTab,
componentProps: { surveyId: survey.id, email },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.SOCIAL_MEDIA,
@@ -141,6 +145,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.share.social_media.description"),
componentType: SocialMediaTab,
componentProps: { surveyUrl, surveyTitle: survey.name },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.QR_CODE,
@@ -151,6 +156,7 @@ export const ShareSurveyModal = ({
description: t("environments.surveys.summary.qr_code_description"),
componentType: QRCodeTab,
componentProps: { surveyUrl },
disabled: survey.singleUse?.enabled,
},
{
id: ShareViaType.DYNAMIC_POPUP,
@@ -177,9 +183,9 @@ export const ShareSurveyModal = ({
t,
survey,
publicDomain,
setSurveyUrl,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
@@ -188,9 +194,15 @@ export const ShareSurveyModal = ({
]
);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(
survey.type === "link" ? ShareViaType.ANON_LINKS : ShareViaType.APP
);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {
return ShareViaType.APP;
}
return ShareViaType.ANON_LINKS;
}, [survey.type]);
const [activeId, setActiveId] = useState<ShareViaType | ShareSettingsType>(getDefaultActiveId());
useEffect(() => {
if (open) {
@@ -198,11 +210,19 @@ export const ShareSurveyModal = ({
}
}, [open, modalView]);
// Ensure active tab is not disabled - if it is, switch to default
useEffect(() => {
const activeTab = linkTabs.find((tab) => tab.id === activeId);
if (activeTab?.disabled) {
setActiveId(getDefaultActiveId());
}
}, [activeId, linkTabs, getDefaultActiveId]);
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (!open) {
setShowView("start");
setActiveId(ShareViaType.ANON_LINKS);
setActiveId(getDefaultActiveId());
}
};

View File

@@ -150,13 +150,13 @@ export const LinkSettingsTab = ({ isReadOnly, locale }: LinkSettingsTabProps) =>
name: "title",
label: t("environments.surveys.share.link_settings.link_title"),
description: t("environments.surveys.share.link_settings.link_title_description"),
placeholder: t("environments.surveys.share.link_settings.link_title_placeholder"),
placeholder: survey.name,
},
{
name: "description",
label: t("environments.surveys.share.link_settings.link_description"),
description: t("environments.surveys.share.link_settings.link_description_description"),
placeholder: t("environments.surveys.share.link_settings.link_description_placeholder"),
placeholder: "Please complete this survey.",
},
];

View File

@@ -34,6 +34,7 @@ interface ShareViewProps {
componentProps: any;
title: string;
description?: string;
disabled?: boolean;
}>;
activeId: ShareViaType | ShareSettingsType;
setActiveId: React.Dispatch<React.SetStateAction<ShareViaType | ShareSettingsType>>;
@@ -109,12 +110,13 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
onClick={() => setActiveId(tab.id)}
className={cn(
"flex w-full justify-start rounded-md p-2 text-slate-600 hover:bg-slate-100 hover:text-slate-900",
tab.id === activeId
tab.id === activeId && !tab.disabled
? "bg-slate-100 font-medium text-slate-900"
: "text-slate-700"
)}
tooltip={tab.label}
isActive={tab.id === activeId}>
isActive={tab.id === activeId}
disabled={tab.disabled}>
<tab.icon className="h-4 w-4 text-slate-700" />
<span>{tab.label}</span>
</SidebarMenuButton>
@@ -136,9 +138,10 @@ export const ShareView = ({ tabs, activeId, setActiveId }: ShareViewProps) => {
<Button
variant="ghost"
onClick={() => setActiveId(tab.id)}
disabled={tab.disabled}
className={cn(
"rounded-md px-4 py-2",
tab.id === activeId
tab.id === activeId && !tab.disabled
? "bg-white text-slate-900 shadow-sm hover:bg-white"
: "border-transparent text-slate-700 hover:text-slate-900"
)}>

View File

@@ -0,0 +1,80 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { StatCard } from "./stat-card";
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card",
() => ({
BaseCard: ({
label,
percentage,
tooltipText,
isLoading,
children,
}: {
label: React.ReactNode;
percentage?: number | null;
tooltipText?: React.ReactNode;
isLoading?: boolean;
children: React.ReactNode;
}) => (
<div data-testid="base-card">
<div data-testid="base-card-label">{label}</div>
{percentage !== null && percentage !== undefined && (
<div data-testid="base-card-percentage">{percentage}%</div>
)}
{tooltipText && <div data-testid="base-card-tooltip">{tooltipText}</div>}
<div data-testid="base-card-loading">{isLoading ? "loading" : "not-loading"}</div>
<div data-testid="base-card-children">{children}</div>
</div>
),
})
);
describe("StatCard", () => {
afterEach(() => {
cleanup();
});
test("renders with basic props", () => {
render(<StatCard label="Test Label" value="Test Value" />);
expect(screen.getByTestId("base-card")).toBeInTheDocument();
expect(screen.getByTestId("base-card-label")).toHaveTextContent("Test Label");
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
});
test("passes percentage prop to BaseCard", () => {
render(<StatCard label="Test Label" value="Test Value" percentage={75} />);
expect(screen.getByTestId("base-card-percentage")).toHaveTextContent("75%");
});
test("renders loading state with skeleton", () => {
render(<StatCard label="Test Label" value="Test Value" isLoading={true} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("loading");
const skeleton = screen.getByTestId("base-card-children").querySelector("div");
expect(skeleton).toHaveClass("h-6", "w-12", "animate-pulse", "rounded-full", "bg-slate-200");
expect(screen.queryByText("Test Value")).not.toBeInTheDocument();
});
test("renders value when not loading", () => {
render(<StatCard label="Test Label" value="Test Value" isLoading={false} />);
expect(screen.getByTestId("base-card-loading")).toHaveTextContent("not-loading");
expect(screen.getByText("Test Value")).toBeInTheDocument();
expect(screen.getByText("Test Value")).toHaveClass("text-2xl", "font-bold", "text-slate-800");
});
test("renders dash value correctly", () => {
const dashValue = <span>-</span>;
render(<StatCard label="Test Label" value={dashValue} />);
expect(screen.getByText("-")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,30 @@
"use client";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
import { ReactNode } from "react";
interface StatCardProps {
label: ReactNode;
percentage?: number | null;
value: ReactNode;
tooltipText?: ReactNode;
isLoading?: boolean;
}
export const StatCard = ({
label,
percentage = null,
value,
tooltipText,
isLoading = false,
}: StatCardProps) => {
return (
<BaseCard label={label} percentage={percentage} tooltipText={tooltipText} isLoading={isLoading}>
{isLoading ? (
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
) : (
<p className="text-2xl font-bold text-slate-800">{value}</p>
)}
</BaseCard>
);
};

View File

@@ -1,8 +1,9 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError } from "@formbricks/types/errors";
import { deleteResponsesAndDisplaysForSurvey } from "./survey";
import { deleteResponsesAndDisplaysForSurvey, getQuotasSummary } from "./survey";
// Mock prisma
vi.mock("@formbricks/database", () => ({
@@ -14,6 +15,9 @@ vi.mock("@formbricks/database", () => ({
deleteMany: vi.fn(),
},
$transaction: vi.fn(),
surveyQuota: {
findMany: vi.fn(),
},
},
}));
@@ -81,3 +85,74 @@ describe("Tests for deleteResponsesAndDisplaysForSurvey service", () => {
});
});
});
describe("Tests for getQuotasSummary service", () => {
test("Returns the correct quotas summary", async () => {
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
{
id: "quota123",
name: "Test Quota",
limit: 100,
_count: {
quotaLinks: 0,
},
},
]);
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
id: "quota123",
name: "Test Quota",
limit: 100,
count: 0,
percentage: 0,
},
]);
});
test("Returns 0 percentage if limit is 0", async () => {
vi.mocked(prisma.surveyQuota.findMany).mockResolvedValue([
{
id: "quota123",
name: "Test Quota",
limit: 0,
_count: {
quotaLinks: 0,
},
},
]);
const result = await getQuotasSummary(surveyId);
expect(result).toEqual([
{
id: "quota123",
name: "Test Quota",
limit: 0,
count: 0,
percentage: 0,
},
]);
});
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const { prisma } = await import("@formbricks/database");
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Database error", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
})
);
await expect(getQuotasSummary(surveyId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const { prisma } = await import("@formbricks/database");
vi.mocked(prisma.surveyQuota.findMany).mockRejectedValue(new Error("Database error"));
await expect(getQuotasSummary(surveyId)).rejects.toThrow(Error);
});
});

View File

@@ -1,4 +1,5 @@
import "server-only";
import { convertFloatTo2Decimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -34,3 +35,47 @@ export const deleteResponsesAndDisplaysForSurvey = async (
throw error;
}
};
export const getQuotasSummary = async (surveyId: string) => {
try {
const quotas = await prisma.surveyQuota.findMany({
where: {
surveyId: surveyId,
},
select: {
_count: {
select: {
quotaLinks: {
where: {
status: "screenedIn",
},
},
},
},
id: true,
name: true,
limit: true,
},
orderBy: {
createdAt: "desc",
},
});
return quotas.map((quota) => {
const { _count, ...rest } = quota;
const count = _count.quotaLinks;
return {
...rest,
count,
percentage: quota.limit > 0 ? convertFloatTo2Decimal((count / quota.limit) * 100) : 0,
};
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};

View File

@@ -1,3 +1,4 @@
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
import { getResponseCountBySurveyId } from "@/lib/response/service";
@@ -58,6 +59,11 @@ vi.mock("@formbricks/database", () => ({
},
},
}));
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey", () => ({
getQuotasSummary: vi.fn(),
}));
vi.mock("./utils", () => ({
convertFloatTo2Decimal: vi.fn((num) =>
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
@@ -138,6 +144,23 @@ const mockResponses = [
},
] as any;
const mockQuotas = [
{
id: "quota1",
name: "Quota 1",
limit: 10,
count: 5,
percentage: 50,
},
{
id: "quota2",
name: "Quota 2",
limit: 20,
count: 10,
percentage: 50,
},
];
describe("getSurveySummaryMeta", () => {
beforeEach(() => {
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
@@ -146,7 +169,7 @@ describe("getSurveySummaryMeta", () => {
});
test("calculates meta correctly", () => {
const meta = getSurveySummaryMeta(mockResponses, 10);
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
expect(meta.displayCount).toBe(10);
expect(meta.totalResponses).toBe(3);
expect(meta.startsPercentage).toBe(30);
@@ -155,16 +178,18 @@ describe("getSurveySummaryMeta", () => {
expect(meta.dropOffCount).toBe(1);
expect(meta.dropOffPercentage).toBe(33.33); // (1/3)*100
expect(meta.ttcAverage).toBe(125); // (100+150)/2
expect(meta.quotasCompleted).toBe(0);
expect(meta.quotasCompletedPercentage).toBe(0);
});
test("handles zero display count", () => {
const meta = getSurveySummaryMeta(mockResponses, 0);
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
expect(meta.startsPercentage).toBe(0);
expect(meta.completedPercentage).toBe(0);
});
test("handles zero responses", () => {
const meta = getSurveySummaryMeta([], 10);
const meta = getSurveySummaryMeta([], 10, mockQuotas);
expect(meta.totalResponses).toBe(0);
expect(meta.completedResponses).toBe(0);
expect(meta.dropOffCount).toBe(0);
@@ -702,6 +727,7 @@ describe("getSurveySummary", () => {
// Default mocks for services
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(mockResponses.length);
vi.mocked(getQuotasSummary).mockResolvedValue(mockQuotas);
// For getResponsesForSummary mock, we need to ensure it's correctly used by getSurveySummary
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
// which is used by the actual implementation of getResponsesForSummary.
@@ -1327,8 +1353,17 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }, { default: "Excellent" }],
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
{ id: "col-4", label: { default: "Excellent" } },
],
};
const survey = {
@@ -1410,15 +1445,15 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", es: "Califica estos aspectos" },
required: true,
rows: [
{ default: "Speed", es: "Velocidad" },
{ default: "Quality", es: "Calidad" },
{ default: "Price", es: "Precio" },
{ id: "row-1", label: { default: "Speed", es: "Velocidad" } },
{ id: "row-2", label: { default: "Quality", es: "Calidad" } },
{ id: "row-3", label: { default: "Price", es: "Precio" } },
],
columns: [
{ default: "Poor", es: "Malo" },
{ default: "Average", es: "Promedio" },
{ default: "Good", es: "Bueno" },
{ default: "Excellent", es: "Excelente" },
{ id: "col-1", label: { default: "Poor", es: "Malo" } },
{ id: "col-2", label: { default: "Average", es: "Promedio" } },
{ id: "col-3", label: { default: "Good", es: "Bueno" } },
{ id: "col-4", label: { default: "Excellent", es: "Excelente" } },
],
};
@@ -1587,8 +1622,16 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
],
};
const survey = {
@@ -1721,8 +1764,16 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
rows: [{ default: "Speed" }, { default: "Quality" }, { default: "Price" }],
columns: [{ default: "Poor" }, { default: "Average" }, { default: "Good" }],
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
{ id: "row-3", label: { default: "Price" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Average" } },
{ id: "col-3", label: { default: "Good" } },
],
};
const survey = {
@@ -1785,8 +1836,14 @@ describe("Matrix question type tests", () => {
type: TSurveyQuestionTypeEnum.Matrix,
headline: { default: "Rate these aspects" },
required: true,
rows: [{ default: "Speed" }, { default: "Quality" }],
columns: [{ default: "Poor" }, { default: "Good" }],
rows: [
{ id: "row-1", label: { default: "Speed" } },
{ id: "row-2", label: { default: "Quality" } },
],
columns: [
{ id: "col-1", label: { default: "Poor" } },
{ id: "col-2", label: { default: "Good" } },
],
};
const survey = {
@@ -1849,12 +1906,12 @@ describe("Matrix question type tests", () => {
headline: { default: "Rate these aspects", fr: "Évaluez ces aspects" },
required: true,
rows: [
{ default: "Speed", fr: "Vitesse" },
{ default: "Quality", fr: "Qualité" },
{ id: "row-1", label: { default: "Speed", fr: "Vitesse" } },
{ id: "row-2", label: { default: "Quality", fr: "Qualité" } },
],
columns: [
{ default: "Poor", fr: "Médiocre" },
{ default: "Good", fr: "Bon" },
{ id: "col-1", label: { default: "Poor", fr: "Médiocre" } },
{ id: "col-2", label: { default: "Good", fr: "Bon" } },
],
};

View File

@@ -1,4 +1,5 @@
import "server-only";
import { getQuotasSummary } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/survey";
import { RESPONSES_PER_PAGE } from "@/lib/constants";
import { getDisplayCountBySurveyId } from "@/lib/display/service";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -55,7 +56,8 @@ interface TSurveySummaryResponse {
export const getSurveySummaryMeta = (
responses: TSurveySummaryResponse[],
displayCount: number
displayCount: number,
quotas: TSurveySummary["quotas"]
): TSurveySummary["meta"] => {
const completedResponses = responses.filter((response) => response.finished).length;
@@ -75,6 +77,9 @@ export const getSurveySummaryMeta = (
const dropOffPercentage = responseCount > 0 ? (dropOffCount / responseCount) * 100 : 0;
const ttcAverage = ttcResponseCount > 0 ? ttcSum / ttcResponseCount : 0;
const quotasCompleted = quotas.filter((quota) => quota.count >= quota.limit).length;
const quotasCompletedPercentage = quotas.length > 0 ? (quotasCompleted / quotas.length) * 100 : 0;
return {
displayCount: displayCount || 0,
totalResponses: responseCount,
@@ -84,6 +89,8 @@ export const getSurveySummaryMeta = (
dropOffCount,
dropOffPercentage: convertFloatTo2Decimal(dropOffPercentage),
ttcAverage: convertFloatTo2Decimal(ttcAverage),
quotasCompleted,
quotasCompletedPercentage,
};
};
@@ -736,8 +743,8 @@ export const getQuestionSummary = async (
break;
}
case TSurveyQuestionTypeEnum.Matrix: {
const rows = question.rows.map((row) => getLocalizedValue(row, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column, "default"));
const rows = question.rows.map((row) => getLocalizedValue(row.label, "default"));
const columns = question.columns.map((column) => getLocalizedValue(column.label, "default"));
let totalResponseCount = 0;
// Initialize count object
@@ -755,13 +762,15 @@ export const getQuestionSummary = async (
if (selectedResponses) {
totalResponseCount++;
question.rows.forEach((row) => {
const localizedRow = getLocalizedValue(row, responseLanguageCode);
const localizedRow = getLocalizedValue(row.label, responseLanguageCode);
const colValue = question.columns.find((column) => {
return getLocalizedValue(column, responseLanguageCode) === selectedResponses[localizedRow];
return (
getLocalizedValue(column.label, responseLanguageCode) === selectedResponses[localizedRow]
);
});
const colValueInDefaultLanguage = getLocalizedValue(colValue, "default");
const colValueInDefaultLanguage = getLocalizedValue(colValue?.label, "default");
if (colValueInDefaultLanguage && columns.includes(colValueInDefaultLanguage)) {
countMap[getLocalizedValue(row, "default")][colValueInDefaultLanguage] += 1;
countMap[getLocalizedValue(row.label, "default")][colValueInDefaultLanguage] += 1;
}
});
}
@@ -932,18 +941,26 @@ export const getSurveySummary = reactCache(
const responseIds = hasFilter ? responses.map((response) => response.id) : [];
const displayCount = await getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
});
const [displayCount, quotas] = await Promise.all([
getDisplayCountBySurveyId(surveyId, {
createdAt: filterCriteria?.createdAt,
...(hasFilter && { responseIds }),
}),
getQuotasSummary(surveyId),
]);
const dropOff = getSurveySummaryDropOff(survey, responses, displayCount);
const [meta, questionWiseSummary] = await Promise.all([
getSurveySummaryMeta(responses, displayCount),
getSurveySummaryMeta(responses, displayCount, quotas),
getQuestionSummary(survey, responses, dropOff),
]);
return { meta, dropOff, summary: questionWiseSummary };
return {
meta,
dropOff,
summary: questionWiseSummary,
quotas,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);

View File

@@ -8,8 +8,11 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { cleanup, render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
@@ -114,6 +117,19 @@ vi.mock("next/navigation", () => ({
}),
}));
vi.mock("@/modules/survey/lib/organization", () => ({
getOrganizationIdFromEnvironmentId: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsQuotasEnabled: vi.fn(),
getIsContactsEnabled: vi.fn(),
}));
const mockEnvironmentId = "test-environment-id";
const mockSurveyId = "test-survey-id";
const mockUserId = "test-user-id";
@@ -158,7 +174,6 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
@@ -174,7 +189,6 @@ const mockSession = {
id: mockUserId,
name: mockUser.name,
email: mockUser.email,
image: mockUser.imageUrl,
role: mockUser.role,
plan: "free",
status: "active",
@@ -193,7 +207,10 @@ const mockSurveySummary = {
startsPercentage: 80,
totalResponses: 20,
ttcAverage: 120,
quotasCompleted: 0,
quotasCompletedPercentage: 0,
},
quotas: [],
dropOff: [],
summary: [],
};
@@ -205,6 +222,9 @@ describe("SurveyPage", () => {
environment: mockEnvironment,
isReadOnly: false,
} as unknown as TEnvironmentAuth);
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
vi.mocked(getSurvey).mockResolvedValue(mockSurvey);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getResponseCountBySurveyId).mockResolvedValue(10);

View File

@@ -7,8 +7,10 @@ import { getPublicDomain } from "@/lib/getPublicUrl";
import { getSurvey } from "@/lib/survey/service";
import { getUser } from "@/lib/user/service";
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -41,6 +43,16 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
const isContactsEnabled = await getIsContactsEnabled();
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
if (!organizationId) {
throw new Error(t("common.organization_not_found"));
}
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new Error(t("common.organization_not_found"));
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
const initialSurveySummary = await getSurveySummary(surveyId);
@@ -72,6 +84,7 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
surveyId={params.surveyId}
locale={user.locale ?? DEFAULT_LOCALE}
initialSurveySummary={initialSurveySummary}
isQuotasAllowed={isQuotasAllowed}
/>
<IdBadge id={surveyId} label={t("common.survey_id")} variant="column" />

View File

@@ -9,9 +9,12 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
import { checkMultiLanguagePermission } from "@/modules/ee/multi-language-surveys/lib/actions";
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
import { checkSpamProtectionPermission } from "@/modules/survey/lib/permission";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
@@ -59,9 +62,11 @@ export const getSurveyFilterDataAction = authenticatedActionClient
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
organizationId: organizationId,
access: [
{
type: "organization",
@@ -75,12 +80,20 @@ export const getSurveyFilterDataAction = authenticatedActionClient
],
});
const [tags, { contactAttributes: attributes, meta, hiddenFields }] = await Promise.all([
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
const [tags, { contactAttributes: attributes, meta, hiddenFields }, quotas = []] = await Promise.all([
getTagsByEnvironmentId(survey.environmentId),
getResponseFilteringValues(parsedInput.surveyId),
isQuotasAllowed ? getQuotas(parsedInput.surveyId) : [],
]);
return { environmentTags: tags, attributes, meta, hiddenFields };
return { environmentTags: tags, attributes, meta, hiddenFields, quotas };
});
/**

View File

@@ -89,4 +89,94 @@ describe("QuestionFilterComboBox", () => {
await userEvent.click(comboBoxOpenerButton);
expect(screen.queryByText("X")).not.toBeInTheDocument();
});
test("shows text input for URL meta field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "example.com",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("example.com");
expect(textInput).toBeInTheDocument();
expect(textInput).toHaveAttribute("type", "text");
});
test("text input is disabled when no filter value is selected for URL field", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: undefined,
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toBeDisabled();
});
test("text input calls onChangeFilterComboBoxValue when typing for URL field", async () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
await userEvent.type(textInput, "t");
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith("t");
});
test("shows regular combobox for non-URL meta fields", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "source",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("shows regular combobox for URL field with non-text operations", () => {
const props = {
...defaultProps,
type: "Other",
fieldId: "url",
filterValue: "Equals",
} as any;
render(<QuestionFilterComboBox {...props} />);
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getAllByRole("button").length).toBeGreaterThanOrEqual(2);
});
test("text input handles string filter combo box values correctly", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: "test-url",
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByDisplayValue("test-url");
expect(textInput).toBeInTheDocument();
});
test("text input handles non-string filter combo box values gracefully", () => {
const props = {
...defaultProps,
type: "Meta",
fieldId: "url",
filterValue: "Contains",
filterComboBoxValue: ["array-value"],
} as any;
render(<QuestionFilterComboBox {...props} />);
const textInput = screen.getByRole("textbox");
expect(textInput).toHaveValue("");
});
});

View File

@@ -33,6 +33,7 @@ type QuestionFilterComboBoxProps = {
type?: TSurveyQuestionTypeEnum | Omit<OptionsType, OptionsType.QUESTIONS>;
handleRemoveMultiSelect: (value: string[]) => void;
disabled?: boolean;
fieldId?: string;
};
export const QuestionFilterComboBox = ({
@@ -45,6 +46,7 @@ export const QuestionFilterComboBox = ({
type,
handleRemoveMultiSelect,
disabled = false,
fieldId,
}: QuestionFilterComboBoxProps) => {
const [open, setOpen] = React.useState(false);
const [openFilterValue, setOpenFilterValue] = React.useState<boolean>(false);
@@ -75,6 +77,9 @@ export const QuestionFilterComboBox = ({
(type === TSurveyQuestionTypeEnum.NPS || type === TSurveyQuestionTypeEnum.Rating) &&
(filterValue === "Submitted" || filterValue === "Skipped");
// Check if this is a URL field with string comparison operations that require text input
const isTextInputField = type === OptionsType.META && fieldId === "url";
const filteredOptions = options?.filter((o) =>
(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o)
.toLowerCase()
@@ -161,70 +166,80 @@ export const QuestionFilterComboBox = ({
</DropdownMenuContent>
</DropdownMenu>
)}
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
{isTextInputField ? (
<Input
type="text"
value={typeof filterComboBoxValue === "string" ? filterComboBoxValue : ""}
onChange={(e) => onChangeFilterComboBoxValue(e.target.value)}
disabled={disabled || !filterValue}
className="h-9 rounded-l-none border-none bg-white text-sm focus:ring-offset-0"
/>
) : (
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
<div
className={clsx(
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
)}>
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
filterComboBoxItem
) : (
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
</button>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"flex-1 text-left text-slate-400",
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{t("common.select")}...
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
)}
</button>
)}
<button
type="button"
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
disabled={disabled || isDisabledComboBox || !filterValue}
className={clsx(
"ml-2 flex items-center justify-center",
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
)}>
{open ? (
<ChevronUp className="h-4 w-4 opacity-50" />
) : (
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</button>
</div>
<div className="relative mt-2 h-full">
{open && (
<div className="animate-in absolute top-0 z-10 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
<CommandList>
<div className="p-2">
<Input
type="text"
autoFocus
placeholder={t("common.search") + "..."}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full rounded-md border border-slate-300 p-2 text-sm focus:border-slate-300"
/>
</div>
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
<CommandGroup>
{filteredOptions?.map((o, index) => (
<CommandItem
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
onSelect={() => commandItemOnSelect(o)}
className="cursor-pointer">
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</div>
)}
</div>
</Command>
</div>
</Command>
)}
</div>
);
};

View File

@@ -28,10 +28,12 @@ import {
HomeIcon,
ImageIcon,
LanguagesIcon,
LinkIcon,
ListIcon,
ListOrderedIcon,
MessageSquareTextIcon,
MousePointerClickIcon,
PieChartIcon,
Rows3Icon,
SmartphoneIcon,
StarIcon,
@@ -47,6 +49,7 @@ export enum OptionsType {
OTHERS = "Other Filters",
META = "Meta",
HIDDEN_FIELDS = "Hidden Fields",
QUOTAS = "Quotas",
}
export type QuestionOption = {
@@ -94,12 +97,16 @@ const questionIcons = {
source: ArrowUpFromDotIcon,
action: MousePointerClickIcon,
country: FlagIcon,
url: LinkIcon,
// others
Language: LanguagesIcon,
// tags
[OptionsType.TAGS]: HashIcon,
// quotas
[OptionsType.QUOTAS]: PieChartIcon,
};
const getIcon = (type: string) => {
@@ -120,6 +127,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return getIcon(label);
} else if (type === OptionsType.TAGS) {
return getIcon(OptionsType.TAGS);
} else if (type === OptionsType.QUOTAS) {
return getIcon(OptionsType.QUOTAS);
}
}
};
@@ -131,6 +140,8 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
return "bg-brand-dark";
} else if (type === OptionsType.TAGS) {
return "bg-indigo-500";
} else if (type === OptionsType.QUOTAS) {
return "bg-slate-500";
} else {
return "bg-amber-500";
}
@@ -138,7 +149,7 @@ export const SelectedCommandItem = ({ label, questionType, type }: Partial<Quest
const getLabelStyle = (): string | undefined => {
if (type !== OptionsType.META) return undefined;
return label === "os" ? "uppercase" : "capitalize";
return label === "os" || label === "url" ? "uppercase" : "capitalize";
};
return (

View File

@@ -25,7 +25,7 @@ import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/type
import { OptionsType, QuestionOption, QuestionsComboBox } from "./QuestionsComboBox";
export type QuestionFilterOptions = {
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages";
type: TSurveyQuestionTypeEnum | "Attributes" | "Tags" | "Languages" | "Quotas";
filterOptions: string[];
filterComboBoxOptions: string[];
id: string;
@@ -51,13 +51,14 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
if (!surveyFilterData?.data) return;
const { attributes, meta, environmentTags, hiddenFields } = surveyFilterData.data;
const { attributes, meta, environmentTags, hiddenFields, quotas } = surveyFilterData.data;
const { questionFilterOptions, questionOptions } = generateQuestionAndFilterOptions(
survey,
environmentTags,
attributes,
meta,
hiddenFields
hiddenFields,
quotas
);
setSelectedOptions({ questionFilterOptions, questionOptions });
}
@@ -246,9 +247,9 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<div className="flex w-full flex-wrap gap-3 md:flex-nowrap">
<div
className="grid w-full grid-cols-1 items-center gap-3 md:grid-cols-2"
key={`${s.questionType.id}-${i}`}>
key={`${s.questionType.id}-${i}-${s.questionType.label}`}>
<QuestionsComboBox
key={`${s.questionType.label}-${i}`}
key={`${s.questionType.label}-${i}-${s.questionType.id}`}
options={questionComboBoxOptions}
selected={s.questionType}
onChangeValue={(value) => handleOnChangeQuestionComboBoxValue(value, i)}
@@ -276,6 +277,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
? s?.questionType?.questionType
: s?.questionType?.type
}
fieldId={s?.questionType?.id}
handleRemoveMultiSelect={(value) => handleRemoveMultiSelect(value, i)}
onChangeFilterComboBoxValue={(value) => handleOnChangeFilterComboBoxValue(value, i)}
onChangeFilterValue={(value) => handleOnChangeFilterValue(value, i)}

View File

@@ -37,5 +37,5 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
return redirect(`/environments/${prodEnvironment.id}/settings/billing`);
}
redirect(`/environments/${prodEnvironment.id}/`);
return redirect(`/environments/${prodEnvironment.id}/`);
};

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