Compare commits

..

8 Commits

Author SHA1 Message Date
Piyush Gupta
1a8e4f562c Merge branch 'main' of https://github.com/formbricks/formbricks into fix/Changing-project-name-doesn't-update-in-the-sidebar-and-project-selector 2025-07-07 10:07:49 +05:30
suraj
82f7029958 fix:Changing project name doesn't update in the sidebar and project selector 2025-06-30 15:06:31 +05:30
Suraj
a56b42e7ce Merge branch 'formbricks:main' into main 2025-06-25 17:43:23 +05:30
Suraj
b6223d1b0a Merge branch 'formbricks:main' into main 2025-01-24 19:59:35 +05:30
Dhruwang Jariwala
9a4dd4a60d Merge branch 'main' into main 2024-12-17 09:23:32 +05:30
Suraj
8eabe4aa88 Merge branch 'formbricks:main' into main 2024-12-13 20:16:50 +05:30
Dhruwang Jariwala
43e07b846f Merge branch 'main' into main 2024-12-13 14:55:01 +05:30
suraj
ac324e5e9a resolve BUG #4447 2024-12-12 23:20:22 +05:30
1085 changed files with 27450 additions and 74387 deletions

View File

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

View File

@@ -1,8 +1,12 @@
---
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
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
---
# 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.
@@ -12,7 +16,6 @@ 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
@@ -26,7 +29,6 @@ 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`
@@ -35,22 +37,17 @@ 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
@@ -58,7 +55,6 @@ Configured cascade relationships:
## Common Query Patterns
### Survey with Responses
```typescript
// Include response count and latest responses
const survey = await prisma.survey.findUnique({
@@ -66,40 +62,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,28 +1,23 @@
---
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 - 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.
- 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
- If a feature is part of the Enterprise Edition, use this note:
<Note>
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
</Note>
FEATURE NAME is part of the @Enterprise Edition.
</Note>

View File

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

View File

@@ -1,232 +0,0 @@
---
description: Security best practices and guidelines for writing GitHub Actions and workflows
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
---
# GitHub Actions Security Best Practices
## Required Security Measures
### 1. Set Minimum GITHUB_TOKEN Permissions
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
```yaml
permissions:
contents: read
# Only add additional permissions if absolutely necessary:
# pull-requests: write # for commenting on PRs
# issues: write # for creating/updating issues
# checks: write # for publishing check results
```
### 2. Add Harden-Runner as First Step
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
```yaml
- name: Harden the runner
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit # or 'block' for stricter security
```
### 3. Pin Actions to Full Commit SHA
**Always** pin third-party actions to their full commit SHA, not tags:
```yaml
# ❌ BAD - uses mutable tag
- uses: actions/checkout@v4
# ✅ GOOD - pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### 4. Secure Variable Handling
Prevent command injection by properly quoting variables:
```yaml
# ❌ BAD - potential command injection
run: echo "Processing ${{ inputs.user_input }}"
# ✅ GOOD - properly quoted
env:
USER_INPUT: ${{ inputs.user_input }}
run: echo "Processing ${USER_INPUT}"
```
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
### 5. Environment Variables for Secrets
Store sensitive data in environment variables, not inline:
```yaml
# ❌ BAD
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
# ✅ GOOD
env:
API_TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
```
## Workflow Structure Best Practices
### Required Workflow Elements
```yaml
name: "Descriptive Workflow Name"
on:
# Define specific triggers
push:
branches: [main]
pull_request:
branches: [main]
# Always set explicit permissions
permissions:
contents: read
jobs:
job-name:
name: "Descriptive Job Name"
runs-on: ubuntu-latest
timeout-minutes: 30 # tune per job; standardize repo-wide
# Set job-level permissions if different from workflow level
permissions:
contents: read
steps:
# Always start with Harden-Runner on ubuntu-latest
- name: Harden the runner
uses: step-security/harden-runner@v2
with:
egress-policy: audit
# Pin all actions to commit SHA
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
```
### Input Validation for Actions
For composite actions, always validate inputs:
```yaml
inputs:
user_input:
description: "User provided input"
required: true
runs:
using: "composite"
steps:
- name: Validate input
shell: bash
run: |
# Harden shell and validate input format/content before use
set -euo pipefail
USER_INPUT="${{ inputs.user_input }}"
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
echo "❌ Invalid input format"
exit 1
fi
```
## Docker Security in Actions
### Pin Docker Images to Digests
```yaml
# ❌ BAD - mutable tag
container: node:18
# ✅ GOOD - pinned to digest
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
```
## Common Patterns
### Secure File Operations
```yaml
- name: Process files securely
shell: bash
env:
FILE_PATH: ${{ inputs.file_path }}
run: |
set -euo pipefail # Fail on errors, undefined vars, pipe failures
# Use absolute paths and validate
SAFE_PATH=$(realpath "${FILE_PATH}")
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
echo "❌ Path outside workspace"
exit 1
fi
```
### Artifact Handling
```yaml
- name: Upload artifacts securely
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
with:
name: build-artifacts
path: |
dist/
!dist/**/*.log # Exclude sensitive files
retention-days: 30
```
### GHCR authentication for pulls/scans
```yaml
# Minimal permissions required for GHCR pulls/scans
permissions:
contents: read
packages: read
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
```
## Security Checklist
- [ ] Minimum GITHUB_TOKEN permissions set
- [ ] Harden-Runner added to all ubuntu-latest jobs
- [ ] All third-party actions pinned to commit SHA
- [ ] Input validation implemented for custom actions
- [ ] Variables properly quoted in shell scripts
- [ ] Secrets stored in environment variables
- [ ] Docker images pinned to digests (if used)
- [ ] Error handling with `set -euo pipefail`
- [ ] File paths validated and sanitized
- [ ] No sensitive data in logs or outputs
- [ ] GHCR login performed before pulls/scans (packages: read)
- [ ] Job timeouts configured (`timeout-minutes`)
## Recommended Additional Workflows
Consider adding these security-focused workflows to your repository:
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
3. **Dependabot Configuration** - Automated dependency updates
## Resources
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)

View File

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

View File

@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: {
filter: [],
responseStatus: "all",
onlyComplete: false,
},
setSelectedFilter: vi.fn(),
selectedOptions: {
@@ -291,6 +291,11 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled();
});
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender();
await waitFor(() => {

View File

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

View File

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

View File

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

View File

@@ -62,12 +62,10 @@ runs:
shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: |
RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
shell: bash
- run: |

View File

@@ -0,0 +1,121 @@
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
runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate Sentry auth token
shell: bash
run: |
set -euo pipefail
echo "🔐 Validating Sentry authentication token..."
# Assign token to local variable for secure handling
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
# Test the token by making a simple API call to Sentry
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
-H "Authorization: Bearer $SENTRY_TOKEN" \
"https://sentry.io/api/0/organizations/formbricks/")
http_code=$(echo "$response" | tail -n1)
if [ "$http_code" != "200" ]; then
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
if [ -f /tmp/sentry_response.json ]; then
echo "Response body:"
cat /tmp/sentry_response.json
fi
exit 1
fi
echo "✅ Sentry auth token validated successfully"
# Clean up temp file
rm -f /tmp/sentry_response.json
- name: Extract sourcemaps from Docker image
shell: bash
run: |
set -euo pipefail
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
# Create temporary container from the image and capture its ID
echo "Creating temporary container..."
CONTAINER_ID=$(docker create "${{ inputs.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: production
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"

View File

@@ -0,0 +1,82 @@
name: "Apply issue labels to PR"
on:
pull_request_target:
types:
- opened
permissions:
contents: read
jobs:
label_on_pr:
runs-on: ubuntu-latest
permissions:
contents: none
issues: read
pull-requests: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: Apply labels from linked issue to PR
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
async function getLinkedIssues(owner, repo, prNumber) {
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
closingIssuesReferences(first: 10) {
nodes {
number
labels(first: 10) {
nodes {
name
}
}
}
}
}
}
}`;
const variables = {
owner: owner,
repo: repo,
prNumber: prNumber,
};
const result = await github.graphql(query, variables);
return result.repository.pullRequest.closingIssuesReferences.nodes;
}
const pr = context.payload.pull_request;
const linkedIssues = await getLinkedIssues(
context.repo.owner,
context.repo.repo,
pr.number
);
const labelsToAdd = new Set();
for (const issue of linkedIssues) {
if (issue.labels && issue.labels.nodes) {
for (const label of issue.labels.nodes) {
labelsToAdd.add(label.name);
}
}
}
if (labelsToAdd.size) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: Array.from(labelsToAdd),
});
}

View File

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

@@ -6,14 +6,12 @@ on:
- main
workflow_dispatch:
permissions:
contents: read
jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
actions: read

27
.github/workflows/dependency-review.yml vendored Normal file
View File

@@ -0,0 +1,27 @@
# Dependency Review Action
#
# This Action will scan dependency manifest files that change as part of a Pull Request,
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
# Once installed, if the workflow run is marked as required,
# PRs introducing known-vulnerable packages will be blocked from merging.
#
# Source repository: https://github.com/actions/dependency-review-action
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: 'Checkout Repository'
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: 'Dependency Review'
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0

View File

@@ -17,8 +17,8 @@ on:
required: true
type: choice
options:
- staging
- production
- stage
- prod
workflow_call:
inputs:
VERSION:
@@ -37,27 +37,21 @@ on:
permissions:
id-token: write
contents: read
contents: write
jobs:
helmfile-deploy:
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
uses: actions/checkout@v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
@@ -71,9 +65,9 @@ jobs:
env:
AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Production
if: inputs.ENVIRONMENT == 'production'
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'prod'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -89,9 +83,9 @@ jobs:
helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
name: Deploy Formbricks Cloud Staging
if: inputs.ENVIRONMENT == 'staging'
- uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'stage'
env:
VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -107,20 +101,19 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: |
# Set hostname based on environment
if [[ "$ENVIRONMENT" == "production" ]]; then
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com"
else
PURGE_HOST="stage.app.formbricks.com"
fi
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
# Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF

View File

@@ -39,68 +39,42 @@ jobs:
--health-retries 5
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
with:
fetch-depth: 0
uses: actions/checkout@v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@v3
- name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
env:
GITHUB_SHA: ${{ github.sha }}
uses: docker/build-push-action@v6
with:
context: .
file: ./apps/web/Dockerfile
push: false
load: true
tags: formbricks-test:${{ env.GITHUB_SHA }}
tags: formbricks-test:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify and Initialize PostgreSQL
- name: Verify PostgreSQL Connection
run: |
echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql with timeout and proper error handling
echo "Testing PostgreSQL connection with 30 second timeout..."
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
echo "Waiting for PostgreSQL to be ready..."
sleep 2
done'; then
echo "✅ PostgreSQL connection successful"
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
# Enable necessary extensions that might be required by migrations
echo "Enabling required PostgreSQL extensions..."
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
else
echo "❌ PostgreSQL connection failed after 30 seconds"
exit 1
fi
# Test connection using psql
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
# Show network configuration
echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check
shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: |
echo "🧪 Testing if the Docker image starts correctly..."
@@ -112,12 +86,29 @@ jobs:
$DOCKER_RUN_ARGS \
-p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
-d "formbricks-test:$GITHUB_SHA"
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d formbricks-test:${{ github.sha }}
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
# Give it more time to start up
echo "Waiting 45 seconds for application to start..."
sleep 45
# Check if the container is running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
echo "❌ Container failed to start properly!"
docker logs formbricks-test
exit 1
else
echo "✅ Container started successfully!"
fi
# Try connecting to PostgreSQL from inside the container
echo "Testing PostgreSQL connection from inside container..."
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
# Try to access the health endpoint
echo "🏥 Testing /health endpoint..."
MAX_RETRIES=10
RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false
@@ -125,32 +116,38 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1))
# Check if container is still running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
echo "📋 Container logs:"
docker logs formbricks-test
exit 1
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Show container logs before each attempt to help debugging
if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Current container logs:"
docker logs --tail 20 formbricks-test
fi
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
echo "📋 Recent container logs:"
docker logs --tail 10 formbricks-test
# Get detailed curl output for debugging
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
CURL_EXIT_CODE=$?
echo "Curl exit code: $CURL_EXIT_CODE"
echo "Curl output: $HTTP_OUTPUT"
if [ $CURL_EXIT_CODE -eq 0 ]; then
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
echo "Status code detected: $STATUS_CODE"
if [ "$STATUS_CODE" = "200" ]; then
echo "✅ Health check successful!"
HEALTH_CHECK_SUCCESS=true
break
else
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
fi
else
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
fi
# Try health endpoint with shorter timeout for faster polling
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
HEALTH_CHECK_SUCCESS=true
break
fi
# Wait 5 seconds before next attempt
sleep 5
echo "Waiting 15 seconds before next attempt..."
sleep 15
done
# Show full container logs for debugging
@@ -163,7 +160,7 @@ jobs:
# Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
echo "❌ Health check failed after $MAX_RETRIES attempts"
exit 1
fi

View File

@@ -1,70 +0,0 @@
name: Docker Security Scan
on:
schedule:
- cron: "0 2 * * *" # Daily at 2 AM UTC
workflow_dispatch:
workflow_run:
workflows: ["Docker Release to Github"]
types: [completed]
permissions:
contents: read
packages: read
security-events: write
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:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
with:
image-ref: "ghcr.io/${{ github.repository }}:latest"
format: "sarif"
output: "trivy-results.sarif"
severity: "CRITICAL,HIGH,MEDIUM,LOW"
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
if: ${{ always() }}
with:
sarif_file: "trivy-results.sarif"
ref: ${{ steps.gitref.outputs.ref }}
sha: ${{ steps.gitref.outputs.sha }}
category: "trivy-container-scan"

View File

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

View File

@@ -1,29 +1,20 @@
name: Build, release & deploy Formbricks images
on:
release:
types: [published]
permissions:
contents: read
workflow_dispatch:
push:
tags:
- "v*"
jobs:
docker-build:
name: Build & release docker image
permissions:
contents: read
packages: write
id-token: write
name: Build & release stable docker image
if: startsWith(github.ref, 'refs/tags/v')
uses: ./.github/workflows/release-docker-github.yml
secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release:
name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit
needs:
@@ -33,9 +24,6 @@ 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:
@@ -43,6 +31,26 @@ jobs:
- helm-chart-release
with:
VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
ENVIRONMENT: "prod"
upload-sentry-sourcemaps:
name: Upload Sentry Sourcemaps
runs-on: ubuntu-latest
permissions:
contents: read
needs:
- docker-build
- deploy-formbricks-cloud
steps:
- name: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Upload Sentry Sourcemaps
uses: ./.github/actions/upload-sentry-sourcemaps
continue-on-error: true
with:
docker_image: 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 }}

View File

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

View File

@@ -29,8 +29,6 @@ jobs:
# with sigstore/fulcio when running outside of PRs.
id-token: write
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -39,50 +37,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- name: Generate SemVer version from branch or tag
id: generate_version
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
# Get reference name and type from environment variables
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 + 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)
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}-${TIMESTAMP}"
echo "Using tag as prerelease with timestamp: $VERSION"
fi
else
# 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}-${TIMESTAMP}"
echo "Using branch as prerelease with timestamp: $VERSION"
fi
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
echo "Generated SemVer version: $VERSION"
- name: Update package.json version
run: |
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
cat ./apps/web/package.json | grep version
- name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -110,9 +64,6 @@ 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
@@ -131,9 +82,6 @@ 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
@@ -148,4 +96,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}"
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}

View File

@@ -7,12 +7,6 @@ name: Docker Release to Github
on:
workflow_call:
inputs:
IS_PRERELEASE:
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
outputs:
VERSION:
description: release version
@@ -26,9 +20,6 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
@@ -54,23 +45,10 @@ jobs:
- name: Get Release Tag
id: extract_release_tag
run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
TAG="$GITHUB_REF"
TAG=${{ github.ref }}
TAG=${TAG#refs/tags/v}
# Validate the extracted tag format
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Original ref: $GITHUB_REF"
echo "Extracted tag: $TAG"
exit 1
fi
# Safely add to environment variables
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version
run: |
@@ -103,13 +81,6 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
@@ -128,7 +99,6 @@ 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

@@ -26,23 +26,8 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate input version
env:
INPUT_VERSION: ${{ inputs.VERSION }}
run: |
set -euo pipefail
# Validate input version format (expects clean semver without 'v' prefix)
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
echo "Expected: clean version without 'v' prefix"
echo "Provided: $INPUT_VERSION"
exit 1
fi
# Store validated version in environment variable
echo "VERSION<<EOF" >> $GITHUB_ENV
echo "$INPUT_VERSION" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Extract release version
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
- name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -50,18 +35,15 @@ jobs:
version: latest
- name: Log in to GitHub Container Registry
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
- name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version
run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart
run: |
@@ -69,4 +51,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry
run: |
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts

81
.github/workflows/scorecard.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
# This workflow uses actions that are not certified by GitHub. They are provided
# by a third-party and are governed by separate terms of service, privacy
# policy, and support documentation.
name: Scorecard supply-chain security
on:
# For Branch-Protection check. Only the default branch is supported. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
branch_protection_rule:
# To guarantee Maintained check is occasionally updated. See
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
schedule:
- cron: "17 17 * * 6"
push:
branches: ["main"]
workflow_dispatch:
# Declare default permissions as read only.
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
# Needed to upload the results to code-scanning dashboard.
security-events: write
# Needed to publish results and get a badge (see publish_results below).
id-token: write
# Add this permission
actions: write # Required for artifact upload
# Uncomment the permissions below if installing in a private repository.
# contents: read
# actions: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- name: "Checkout code"
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: "Run analysis"
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
with:
results_file: results.sarif
results_format: sarif
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecard on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
# - Allows the repository to include the Scorecard badge.
# - See https://github.com/ossf/scorecard-action#publishing-results.
# For private repositories:
# - `publish_results` will always be set to `false`, regardless
# of the value entered here.
publish_results: true
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
# format to the repository Actions tab.
- name: "Upload artifact"
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sarif
path: results.sarif
retention-days: 5
# Upload the results to GitHub's code scanning dashboard (optional).
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
with:
sarif_file: results.sarif

View File

@@ -56,3 +56,11 @@ jobs:
```
${{ steps.lint_pr_title.outputs.error_message }}
```
# Delete a previous comment when the issue has been resolved
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
with:
header: pr-title-lint-error
message: |
Thank you for following the naming conventions for pull request titles! 🙏

View File

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

View File

@@ -14,14 +14,12 @@ on:
paths:
- "infra/terraform/**"
permissions:
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
pull-requests: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -35,7 +33,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
uses: tailscale/github-action@v3
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}

View File

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

View File

@@ -27,18 +27,10 @@ jobs:
- name: Get source branch name
id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: |
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
RAW_BRANCH="${{ github.head_ref }}"
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Additional validation - ensure branch name is not empty after sanitization
if [[ -z "$SOURCE_BRANCH" ]]; then
echo "❌ Error: Branch name is empty after sanitization"
echo "Original branch: $RAW_BRANCH"
exit 1
fi
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks

View File

@@ -0,0 +1,46 @@
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: Checkout
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set Docker Image
run: |
if [ -n "${{ inputs.tag_version }}" ]; then
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
else
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
fi
- 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

@@ -0,0 +1,32 @@
name: "Welcome new contributors"
on:
issues:
types: opened
pull_request_target:
types: opened
permissions:
pull-requests: write
issues: write
jobs:
welcome-message:
name: Welcoming New Users
runs-on: ubuntu-latest
timeout-minutes: 10
if: github.event.action == 'opened'
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
with:
egress-policy: audit
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: |-
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
issue-message: |
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊

View File

@@ -31,18 +31,6 @@
{
"language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json"
},
{
"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,7 +21,6 @@ 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,25 +1,23 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/**
* This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/
function getAbsolutePath(value: string): any {
const getAbsolutePath = (value: string) => {
return dirname(require.resolve(join(value, "package.json")));
}
};
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
],
framework: {
name: getAbsolutePath("@storybook/react-vite"),

View File

@@ -1,32 +1,5 @@
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 type { Preview } from "@storybook/react";
import "../../web/modules/ui/globals.css";
import { TolgeeBase } from "../../web/tolgee/shared";
// Create a Storybook-specific Tolgee decorator
const withTolgee = (Story: any) => {
const tolgee = TolgeeBase().init({
tagNewKeys: [], // No branch tagging in Storybook
});
return React.createElement(
TolgeeProvider,
{
tolgee,
fallback: "Loading",
ssr: {
language: "en-US",
staticData: {
"en-US": enUSTranslations,
},
},
},
React.createElement(Story)
);
};
const preview: Preview = {
parameters: {
@@ -37,7 +10,6 @@ const preview: Preview = {
},
},
},
decorators: [withTolgee],
};
export default preview;

View File

@@ -14,19 +14,23 @@
"eslint-plugin-react-refresh": "0.4.20"
},
"devDependencies": {
"@chromatic-com/storybook": "^4.0.1",
"@storybook/addon-a11y": "9.0.15",
"@storybook/addon-links": "9.0.15",
"@storybook/addon-onboarding": "9.0.15",
"@storybook/react-vite": "9.0.15",
"@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "8.6.12",
"@storybook/addon-essentials": "8.6.12",
"@storybook/addon-interactions": "8.6.12",
"@storybook/addon-links": "8.6.12",
"@storybook/addon-onboarding": "8.6.12",
"@storybook/blocks": "8.6.12",
"@storybook/react": "8.6.12",
"@storybook/react-vite": "8.6.12",
"@storybook/test": "8.6.12",
"@typescript-eslint/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15",
"eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1",
"storybook": "9.0.15",
"vite": "6.3.5",
"@storybook/addon-docs": "9.0.15"
"storybook": "8.6.12",
"vite": "6.3.5"
}
}

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs/blocks";
import { Meta } from "@storybook/blocks";
import Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png";

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.22 AS base
FROM node:22-alpine3.21 AS base
#
## step 1: Prune monorepo
@@ -30,13 +30,9 @@ 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=8192"
ARG NODE_OPTIONS="--max_old_space_size=4096"
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
@@ -61,7 +57,6 @@ 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

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

View File

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

View File

@@ -10,27 +10,48 @@ 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 } from "lucide-react";
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMemo, 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 = ({ user, organization }: LandingSidebarProps) => {
export const LandingSidebar = ({
isMultiOrgEnabled,
user,
organization,
organizations,
}: 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"),
@@ -40,6 +61,13 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
},
];
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(
@@ -52,28 +80,27 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
<DropdownMenuTrigger
asChild
id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<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>
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
<>
<div>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 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="max-w-28 truncate text-sm text-slate-500">
{capitalizeFirstLetter(organization?.name)}
</p>
</div>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -85,13 +112,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
{/* Dropdown Items */}
{dropdownNavigation.map((link) => (
<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">
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
<DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label}
@@ -100,6 +121,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
))}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
await signOutWithAudit({
@@ -114,6 +136,45 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
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

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

View File

@@ -1,4 +1,3 @@
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";
@@ -16,7 +15,6 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
isPendingDowngrade: false,
fallbackLevel: "live",
}),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
}));
vi.mock("@/lib/constants", () => ({
@@ -99,36 +97,20 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
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
@@ -160,7 +142,6 @@ 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" } });
@@ -182,7 +163,6 @@ 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" } });
@@ -193,16 +173,10 @@ describe("Page component", () => {
test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } },
organization: { id: "org1", billing: { plan: "free" } },
organization: { id: "org1" },
} 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 || ""
);
@@ -214,13 +188,11 @@ 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,11 +1,7 @@
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 { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server";
@@ -26,38 +22,24 @@ const Page = async (props) => {
const organizations = await getOrganizationsByUserId(session.user.id);
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
const { features } = await getEnterpriseLicense();
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
const { isMember } = getAccessFlags(membership?.role);
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
return (
<div className="flex min-h-full min-w-full flex-row">
<LandingSidebar user={user} organization={organization} />
<LandingSidebar
user={user}
organization={organization}
isMultiOrgEnabled={isMultiOrgEnabled}
organizations={organizations}
/>
<div className="flex-1">
<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 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>

View File

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

View File

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

View File

@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind",
defaultBrandColor: "#fff",
organizationTeams: [],
isAccessControlAllowed: false,
canDoRoleManagement: false,
userProjectsCount: 0,
} as any;

View File

@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
canDoRoleManagement: boolean;
userProjectsCount: number;
}
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
canDoRoleManagement = false,
userProjectsCount,
}: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{canDoRoleManagement && userProjectsCount > 0 && (
<FormField
control={form.control}
name="teamIds"

View File

@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
});
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
const element = await Page({ params, searchParams });
render(element as React.ReactElement);

View File

@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
canDoRoleManagement={canDoRoleManagement}
userProjectsCount={projects.length}
/>
{projects.length >= 1 && (

View File

@@ -18,6 +18,11 @@ 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", () => ({
@@ -53,6 +58,7 @@ 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,5 +1,6 @@
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";
@@ -31,6 +32,7 @@ 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

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

View File

@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
}
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!isAccessControlAllowed) {
if (!canDoRoleManagement) {
throw new OperationNotAllowedError("You do not have permission to manage roles");
}
}
@@ -71,6 +71,10 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: {
...user.notificationSettings?.alert,
},
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
};
await updateUser(user.id, {

View File

@@ -24,17 +24,14 @@ export const ActionClassesTable = ({
otherEnvActionClasses,
otherEnvironment,
}: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = (
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
e.preventDefault();
setActiveActionClass(actionClass);
setIsActionDetailModalOpen(true);
setActionDetailModalOpen(true);
};
return (
@@ -45,7 +42,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => (
<button
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
onClick={(e) => {
handleOpenActionDetailModalClick(e, actionClass);
}}
className="w-full"
@@ -66,7 +63,7 @@ export const ActionClassesTable = ({
environmentId={environmentId}
environment={environment}
open={isActionDetailModalOpen}
setOpen={setIsActionDetailModalOpen}
setOpen={setActionDetailModalOpen}
actionClasses={actionClasses}
actionClass={activeActionClass}
isReadOnly={isReadOnly}

View File

@@ -70,13 +70,15 @@ export const ActionDetailModal = ({
};
return (
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
<>
<ModalWithTabs
open={open}
setOpen={setOpen}
tabs={tabs}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
label={actionClass.name}
description={typeDescription()}
/>
</>
);
};

View File

@@ -11,21 +11,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(),
}));
// Mock action utils
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
useActionClassKeys: vi.fn(() => ["existing-key"]),
createActionClassZodResolver: vi.fn(() => vi.fn()),
validatePermissions: vi.fn(),
}));
// Mock action builder
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
buildActionObject: vi.fn((data, environmentId, t) => ({
...data,
environmentId,
})),
}));
// Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -39,7 +24,6 @@ vi.mock("@/modules/ui/components/button", () => ({
</button>
),
}));
vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -47,7 +31,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div>
),
}));
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? (
@@ -60,26 +43,6 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div>
) : null,
}));
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
<div data-testid="action-name-description-fields">
<input
data-testid={`name-input-${nameInputId}`}
placeholder="environments.actions.eg_clicked_download"
disabled={isReadOnly}
defaultValue="Test Action"
/>
<input
data-testid={`description-input-${descriptionInputId}`}
placeholder="environments.actions.user_clicked_download_button"
disabled={isReadOnly}
defaultValue="Test Description"
/>
</div>
),
}));
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
@@ -93,23 +56,6 @@ vi.mock("lucide-react", () => ({
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
}));
// Mock react-hook-form
const mockHandleSubmit = vi.fn();
const mockForm = {
handleSubmit: mockHandleSubmit,
control: {},
formState: { errors: {} },
};
vi.mock("react-hook-form", async () => {
const actual = await vi.importActual("react-hook-form");
return {
...actual,
useForm: vi.fn(() => mockForm),
FormProvider: ({ children }: any) => <div>{children}</div>,
};
});
const mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [
{
@@ -142,7 +88,6 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
});
afterEach(() => {
@@ -160,9 +105,13 @@ describe("ActionSettingsTab", () => {
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
@@ -182,104 +131,18 @@ describe("ActionSettingsTab", () => {
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
});
test("renders correctly for other action types (fallback)", () => {
const actionClass = {
...createMockActionClass("auto1", "noCode", "Auto Action"),
type: "automatic" as any,
};
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
expect(
screen.getByText(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)
).toBeInTheDocument();
});
test("calls utility functions on initialization", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles successful form submission", async () => {
const { updateActionClassAction } = await import(
"@/app/(app)/environments/[environmentId]/actions/actions"
);
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
// Check that utility functions were called during component initialization
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
});
test("handles permission validation error", async () => {
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
throw new Error("Not authorized");
});
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
mockHandleSubmit.mockImplementation((fn) => (e) => {
e.preventDefault();
return fn({ name: "Test", type: "noCode" });
});
await userEvent.click(submitButton);
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("Not authorized");
});
});
test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import(
@@ -346,16 +209,17 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={true}
isReadOnly={true} // Set to read-only
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
});
test("prevents delete when read-only", async () => {
@@ -364,6 +228,7 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions"
);
// Render with isReadOnly=true, but simulate a delete attempt
render(
<ActionSettingsTab
actionClass={actionClass}
@@ -373,6 +238,12 @@ describe("ActionSettingsTab", () => {
/>
);
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
// This test primarily checks the logic within handleDeleteAction if it were called.
// A better approach might be to export handleDeleteAction for direct testing,
// but for now, we assume the UI prevents calling it.
// We can assert that the delete button isn't there to prevent the flow
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled();
});
@@ -391,19 +262,4 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank");
});
test("uses correct input IDs for ActionNameDescriptionFields", () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
render(
<ActionSettingsTab
actionClass={actionClass}
actionClasses={mockActionClasses}
setOpen={mockSetOpen}
isReadOnly={false}
/>
);
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
});
});

View File

@@ -4,17 +4,14 @@ import {
deleteActionClassAction,
updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions";
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react";
import Link from "next/link";
@@ -22,7 +19,8 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
import { z } from "zod";
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
interface ActionSettingsTabProps {
actionClass: TActionClass;
@@ -50,51 +48,63 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses]
);
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
const form = useForm<TActionClassInput>({
defaultValues: {
...restActionClass,
},
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
resolver: zodResolver(
ZActionClassInput.superRefine((data, ctx) => {
if (data.name && actionClassNames.includes(data.name)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["name"],
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
});
}
})
),
mode: "onChange",
});
const { handleSubmit, control } = form;
const renderActionForm = () => {
if (actionClass.type === "code") {
return (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
);
}
if (actionClass.type === "noCode") {
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
}
return (
<p className="text-sm text-slate-600">
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
</p>
);
};
const onSubmit = async (data: TActionClassInput) => {
try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true);
validatePermissions(isReadOnly, t);
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
if (data.name && actionClassNames.includes(data.name)) {
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
}
if (
data.type === "noCode" &&
data.noCodeConfig?.type === "click" &&
data.noCodeConfig.elementSelector.cssSelector &&
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
) {
throw new Error(t("environments.actions.invalid_css_selector"));
}
const updatedData: TActionClassInput = {
...data,
...(data.type === "noCode" &&
data.noCodeConfig?.type === "click" && {
noCodeConfig: {
...data.noCodeConfig,
elementSelector: {
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
},
},
}),
};
await updateActionClassAction({
actionClassId: actionClass.id,
updatedAction: updatedAction,
updatedAction: updatedData,
});
setOpen(false);
router.refresh();
@@ -113,7 +123,7 @@ export const ActionSettingsTab = ({
router.refresh();
toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false);
} catch {
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingAction(false);
@@ -125,14 +135,79 @@ export const ActionSettingsTab = ({
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<ActionNameDescriptionFields
control={control}
isReadOnly={isReadOnly}
nameInputId="actionNameSettingsInput"
descriptionInputId="actionDescriptionSettingsInput"
/>
<div className="grid w-full grid-cols-2 gap-x-4">
<div className="col-span-1">
<FormField
control={control}
name="name"
disabled={isReadOnly}
render={({ field, fieldState: { error } }) => (
<FormItem>
<FormLabel htmlFor="actionNameSettingsInput">
{actionClass.type === "noCode"
? t("environments.actions.what_did_your_user_do")
: t("environments.actions.display_name")}
</FormLabel>
{renderActionForm()}
<FormControl>
<Input
type="text"
id="actionNameSettingsInput"
{...field}
placeholder={t("environments.actions.eg_clicked_download")}
isInvalid={!!error?.message}
disabled={isReadOnly}
/>
</FormControl>
<FormError />
</FormItem>
)}
/>
</div>
<div className="col-span-1">
<FormField
control={control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel htmlFor="actionDescriptionSettingsInput">
{t("common.description")}
</FormLabel>
<FormControl>
<Input
type="text"
id="actionDescriptionSettingsInput"
{...field}
placeholder={t("environments.actions.user_clicked_download_button")}
value={field.value ?? ""}
disabled={isReadOnly}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
{actionClass.type === "code" ? (
<>
<CodeActionForm form={form} isReadOnly={true} />
<p className="text-sm text-slate-600">
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
</p>
</>
) : actionClass.type === "noCode" ? (
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
) : (
<p className="text-sm text-slate-600">
{t(
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
)}
</p>
)}
</div>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">

View File

@@ -1,5 +1,3 @@
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";
@@ -7,20 +5,22 @@ import {
getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations";
import {
TOrganization,
TOrganizationBilling,
TOrganizationBillingPlanLimits,
} from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user";
@@ -31,12 +31,16 @@ 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(),
}));
@@ -45,33 +49,13 @@ vi.mock("@/lib/membership/utils", () => ({
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(),
getAccessControlPermission: vi.fn(),
}));
vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(),
}));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key,
}));
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;
@@ -87,17 +71,15 @@ vi.mock("@/lib/constants", () => ({
// Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
<div data-testid="main-navigation">
MainNavigation
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
</div>
),
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
}));
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>,
}));
@@ -117,20 +99,23 @@ const mockUser = {
name: "Test User",
email: "test@example.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {} },
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockOrganization = {
id: "org-1",
name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
plan: "free",
limits: {},
},
stripeCustomerId: null,
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
} as unknown as TOrganizationBilling,
} as unknown as TOrganization;
const mockEnvironment: TEnvironment = {
@@ -171,17 +156,6 @@ const mockProjectPermission = {
role: "admin",
} as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = {
user: {
id: "user-1",
@@ -193,19 +167,15 @@ describe("EnvironmentLayout", () => {
beforeEach(() => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
{ id: mockOrganization.id, name: mockOrganization.name },
]);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false;
mockIsFormbricksCloud = false;
});
@@ -244,6 +214,33 @@ 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();
@@ -291,84 +288,6 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
});
test("handles empty organizationTeams array", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles null organizationTeams", async () => {
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
});
test("handles isAccessControlAllowed false", async () => {
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({
active: false,
isPendingDowngrade: false,
features: { isMultiOrgEnabled: false },
lastChecked: new Date(),
fallbackLevel: "live",
}),
}));
const { EnvironmentLayout } = await import(
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
);
render(
await EnvironmentLayout({
environmentId: "env-1",
session: mockSession,
children: <div>Child Content</div>,
})
);
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
});
test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules();
@@ -430,7 +349,7 @@ describe("EnvironmentLayout", () => {
});
test("throws error if projects, environments or organizations not found", async () => {
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
vi.mocked(getUserProjects).mockResolvedValue(null as any);
vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({

View File

@@ -1,7 +1,5 @@
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";
@@ -10,14 +8,14 @@ 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 {
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
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";
@@ -50,22 +48,17 @@ 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([
getProjectsByUserId(user.id, currentUserMembership),
const [projects, environments] = await Promise.all([
getUserProjects(user.id, organization.id),
getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]);
if (!projects || !environments || !organizations) {
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();
@@ -90,17 +83,10 @@ 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}
@@ -115,36 +101,30 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false}
active={active}
environmentId={environment.id}
locale={user.locale}
/>
<div className="flex h-full">
<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}
/>
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
<div id="mainContent" className="flex-1 overflow-y-auto 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}
/>
<div className="flex-1 overflow-y-auto">{children}</div>
<div className="mt-14">{children}</div>
</div>
</div>
</div>

View File

@@ -51,6 +51,13 @@ 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 }: { isCollapsed: boolean }) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
}));
@@ -93,12 +100,13 @@ const mockUser = {
id: "user1",
name: "Test User",
email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(),
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
notificationSettings: { alert: {} },
notificationSettings: { alert: {}, weeklySummary: {} },
role: "project_manager",
objective: "other",
} as unknown as TUser;
@@ -138,7 +146,6 @@ const defaultProps = {
membershipRole: "owner" as const,
organizationProjectsLimit: 5,
isLicenseActive: true,
isAccessControlAllowed: true,
};
describe("MainNavigation", () => {
@@ -159,11 +166,13 @@ 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();
@@ -174,6 +183,7 @@ 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");
});
@@ -188,6 +198,7 @@ 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");
});
@@ -223,6 +234,8 @@ 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();
@@ -243,6 +256,46 @@ 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" });
@@ -270,25 +323,15 @@ describe("MainNavigation", () => {
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
});
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
render(<MainNavigation {...defaultProps} />);
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);
// 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} />);
// Test that navigation renders correctly with no teams
expect(screen.getByRole("complementary")).toBeInTheDocument();
});
test("handles isAccessControlAllowed false", () => {
render(<MainNavigation {...defaultProps} />);
// Test that navigation renders correctly with access control disabled
expect(screen.getByRole("complementary")).toBeInTheDocument();
// Wait for dropdown items
await waitFor(() => {
expect(screen.getByText("common.billing")).toBeInTheDocument();
});
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
});
});

View File

@@ -2,17 +2,26 @@
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";
@@ -21,14 +30,18 @@ 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";
@@ -37,40 +50,53 @@ 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: { id: string; name: string }[];
projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean;
isDevelopment: boolean;
membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
}
export const MainNavigation = ({
environment,
organizations,
organization,
user,
projects,
isMultiOrgEnabled,
membershipRole,
isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment,
}: 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, isBilling } = getAccessFlags(membershipRole);
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
const isOwnerOrManager = isManager || isOwner;
const isPricingDisabled = isMember;
const toggleSidebar = () => {
setIsCollapsed(!isCollapsed);
@@ -91,11 +117,40 @@ export const MainNavigation = ({
}, [isCollapsed]);
useEffect(() => {
// Auto collapse project navbar on org and account settings
if (pathname?.includes("/settings")) {
setIsCollapsed(true);
if (organization && organization.name !== "") {
setCurrentOrganizationName(organization.name);
setCurrentOrganizationId(organization.id);
}
}, [pathname]);
}, [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}/`);
};
const mainNavigation = useMemo(
() => [
@@ -141,14 +196,25 @@ export const MainNavigation = ({
icon: UserCircleIcon,
},
{
label: t("common.documentation"),
href: "https://formbricks.com/docs",
target: "_blank",
icon: ArrowUpRightIcon,
label: t("common.organization"),
href: `/environments/${environment.id}/settings/general`,
icon: UsersIcon,
},
{
label: t("common.share_feedback"),
href: "https://github.com/formbricks/formbricks/issues",
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,
},
@@ -161,7 +227,7 @@ export const MainNavigation = ({
const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
if (currentVersionTag !== latestVersionTag) {
setLatestVersion(latestVersionTag);
}
}
@@ -177,7 +243,8 @@ 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"
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
)}>
<div>
{/* Logo and Toggle */}
@@ -243,6 +310,22 @@ 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}
/>
)}
{/* User Switch */}
<div className="flex items-center">
<DropdownMenu>
@@ -251,27 +334,29 @@ 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"
"flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "pl-2" : "pl-4"
)}>
<ProfileAvatar userId={user.id} />
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && (
<>
<div
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
<p
title={user?.email}
className={cn(
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
)}>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p>
<p className="text-sm text-slate-700">{t("common.account")}</p>
<p
title={capitalizeFirstLetter(organization?.name)}
className="max-w-28 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")}
/>
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</>
)}
</div>
@@ -285,20 +370,24 @@ export const MainNavigation = ({
align="end">
{/* Dropdown Items */}
{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>
))}
{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>
)
)}
{/* Logout */}
<DropdownMenuItem
onClick={async () => {
const route = await signOutWithAudit({
@@ -314,12 +403,55 @@ 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

@@ -28,7 +28,7 @@ const TestComponent = () => {
return (
<div>
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
},
],
responseStatus: "complete",
onlyComplete: true,
})
}>
Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider>
);
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
expect(screen.getByTestId("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton);
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
expect(screen.getByTestId("filterLength").textContent).toBe("1");
});

View File

@@ -16,11 +16,9 @@ export interface FilterValue {
};
}
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue {
filter: FilterValue[];
responseStatus: TResponseStatus;
onlyComplete: boolean;
}
interface SelectedFilterOptions {
@@ -49,7 +47,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [],
responseStatus: "all",
onlyComplete: false,
});
// state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -69,7 +67,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
});
setSelectedFilter({
filter: [],
responseStatus: "all",
onlyComplete: false,
});
}, []);

View File

@@ -0,0 +1,66 @@
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("top-control-buttons").parentElement?.parentElement?.parentElement;
expect(mainDiv).toHaveClass(
"fixed inset-0 top-0 z-30 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,110 +1,32 @@
"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 { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
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 TopControlBarProps {
interface SideBarProps {
environment: TEnvironment;
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,
}: TopControlBarProps) => {
const { t } = useTranslate();
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const { environment } = useEnvironment();
}: SideBarProps) => {
return (
<div
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
data-testid="fb__global-top-control-bar">
<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 className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
<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>
</div>
);

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,47 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organizationId: project.organizationId,
}),
[environment, project]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,6 +128,7 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null,
languages: [],
pin: null,
resultShareKey: null,
segment: null,
singleUse: null,
styling: null,
@@ -191,7 +192,9 @@ 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("./");
expect(screen.getByTestId("go-back")).toHaveTextContent(
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
);
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) {
return redirect("./");
redirect("./");
}
return (
<PageContentWrapper>
<GoBackButton url={"./"} />
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
<NotionWrapper
enabled={enabled}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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