Compare commits

..

2 Commits

Author SHA1 Message Date
Jakob Schott 735c180eaf made minor changes to structure. Included inclusion criteria in setup flow 2025-07-04 16:30:44 +02:00
Johannes 342fe89f0c quota docs 2025-06-19 18:36:22 +02:00
1306 changed files with 32349 additions and 84410 deletions
+18 -22
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. description: >
alwaysApply: false 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 # 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. 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. Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
### Core Hierarchy ### Core Hierarchy
``` ```
Organization Organization
└── Project └── Project
@@ -26,7 +29,6 @@ Organization
## Schema Reference ## Schema Reference
For the complete and up-to-date database schema, please refer to: For the complete and up-to-date database schema, please refer to:
- Main schema: `packages/database/schema.prisma` - Main schema: `packages/database/schema.prisma`
- JSON type definitions: `packages/database/json-types.ts` - 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 ## Data Access Patterns
### Multi-tenancy ### Multi-tenancy
- All data is scoped by Organization - All data is scoped by Organization
- Environment-level isolation for surveys and contacts - Environment-level isolation for surveys and contacts
- Project-level grouping for related surveys - Project-level grouping for related surveys
### Soft Deletion ### Soft Deletion
Some models use soft deletion patterns: Some models use soft deletion patterns:
- Check `isActive` fields where present - Check `isActive` fields where present
- Use proper filtering in queries - Use proper filtering in queries
### Cascading Deletes ### Cascading Deletes
Configured cascade relationships: Configured cascade relationships:
- Organization deletion cascades to all child entities - Organization deletion cascades to all child entities
- Survey deletion removes responses, displays, triggers - Survey deletion removes responses, displays, triggers
- Contact deletion removes attributes and responses - Contact deletion removes attributes and responses
@@ -58,7 +55,6 @@ Configured cascade relationships:
## Common Query Patterns ## Common Query Patterns
### Survey with Responses ### Survey with Responses
```typescript ```typescript
// Include response count and latest responses // Include response count and latest responses
const survey = await prisma.survey.findUnique({ const survey = await prisma.survey.findUnique({
@@ -66,40 +62,40 @@ const survey = await prisma.survey.findUnique({
include: { include: {
responses: { responses: {
take: 10, take: 10,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: 'desc' }
}, },
_count: { _count: {
select: { responses: true }, select: { responses: true }
}, }
}, }
}); });
``` ```
### Environment Scoping ### Environment Scoping
```typescript ```typescript
// Always scope by environment // Always scope by environment
const surveys = await prisma.survey.findMany({ const surveys = await prisma.survey.findMany({
where: { where: {
environmentId: environmentId, environmentId: environmentId,
// Additional filters... // Additional filters...
}, }
}); });
``` ```
### Contact with Attributes ### Contact with Attributes
```typescript ```typescript
const contact = await prisma.contact.findUnique({ const contact = await prisma.contact.findUnique({
where: { id: contactId }, where: { id: contactId },
include: { include: {
attributes: { attributes: {
include: { 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. This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
+6 -10
View File
@@ -1,28 +1,24 @@
--- ---
description: Guideline for writing end-user facing documentation in the apps/docs folder description: Guideline for writing end-user facing documentation in the apps/docs folder
globs: globs:
alwaysApply: false alwaysApply: false
--- ---
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder 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: 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" title: "FEATURE NAME"
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT." description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
icon: "link" icon: "link"
--- ---
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT. - 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. - 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. - In all headlines, only capitalize the current feature and nothing else, NO Camel Case
- The page should never start with H1 headline, because it's already part of the template. - Update the mint.json file and add the nav item at the correct position corresponding to where the MDX file is located
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
- If a feature is part of the Enterprise Edition, use this note: - If a feature is part of the Enterprise Edition, use this note:
<Note> <Note>
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license) FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note> </Note>
@@ -18,6 +18,7 @@ apps/web/
│ ├── (app)/ # Main application routes │ ├── (app)/ # Main application routes
│ ├── (auth)/ # Authentication routes │ ├── (auth)/ # Authentication routes
│ ├── api/ # API routes │ ├── api/ # API routes
│ └── share/ # Public sharing routes
├── components/ # Shared components ├── components/ # Shared components
├── lib/ # Utility functions and services ├── lib/ # Utility functions and services
└── modules/ # Feature-specific modules └── modules/ # Feature-specific modules
@@ -42,6 +43,7 @@ The application uses Next.js 13+ app router with route groups:
### Dynamic Routes ### Dynamic Routes
- `[environmentId]` - Environment-specific routes - `[environmentId]` - Environment-specific routes
- `[surveyId]` - Survey-specific routes - `[surveyId]` - Survey-specific routes
- `[sharingKey]` - Public sharing routes
## Service Layer Pattern ## Service Layer Pattern
-232
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)
-74
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)).
@@ -1,216 +0,0 @@
---
description: Migrate deprecated UI components to a unified component
globs:
alwaysApply: false
---
# Component Migration Automation Rule
## Overview
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
## Trigger
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
## Process
### Step 1: Discovery and Planning
1. **Identify migration parameters:**
- Ask user for deprecated component name (e.g., "Modal")
- Ask user for new component name(s) (e.g., "Dialog")
- Ask for any components to exclude (e.g., "ModalWithTabs")
- Ask for specific import paths if needed
2. **Scan codebase** for deprecated components:
- Search for `import.*[DeprecatedComponent]` patterns
- Exclude specified components that should not be migrated
- List all found components with file paths
- Present numbered list to user for confirmation
### Step 2: Component-by-Component Migration
For each component, follow this exact sequence:
#### 2.1 Component Migration
- **Import changes:**
- Ask user to provide the new import structure
- Example transformation pattern:
```typescript
// FROM:
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
// TO:
import {
[NewComponent],
[NewComponentPart1],
[NewComponentPart2],
// ... other parts
} from "@/components/ui/[NewComponent]"
```
- **Props transformation:**
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
- Apply transformations based on user specifications
- **Structure transformation:**
- Ask user for the new component structure pattern
- Apply the transformation maintaining all functionality
- Preserve all existing logic, state management, and event handlers
#### 2.2 Wait for User Approval
- Present the migration changes
- Wait for explicit user approval before proceeding
- If rejected, ask for specific feedback and iterate
#### 2.3 Re-read and Apply Additional Changes
- Re-read the component file to capture any user modifications
- Apply any additional improvements the user made
- Ensure all changes are incorporated
#### 2.4 Test File Updates
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
- **Update test mocks:**
- Ask user for new component mock structure
- Replace old component mocks with new ones
- Example pattern:
```typescript
// Add to test setup:
jest.mock("@/components/ui/[NewComponent]", () => ({
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
// ... other parts
}));
```
- **Update test expectations:**
- Change test IDs from old component to new component
- Update any component-specific assertions
- Ensure all new component parts used in the component are mocked
#### 2.5 Run Tests and Optimize
- Execute `Node package manager test -- ComponentName.test.tsx`
- Fix any failing tests
- Optimize code quality (imports, formatting, etc.)
- Re-run tests until all pass
- **Maximum 3 iterations** - if still failing, ask user for guidance
#### 2.6 Wait for Final Approval
- Present test results and any optimizations made
- Wait for user approval of the complete migration
- If rejected, iterate based on feedback
#### 2.7 Git Commit
- Run: `git add .`
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
- Confirm commit was successful
### Step 3: Final Report Generation
After all components are migrated, generate a comprehensive GitHub PR report:
#### PR Title
```
feat: migrate [DeprecatedComponent] components to [NewComponent] system
```
#### PR Description Template
```markdown
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
### Overview
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
### Components Migrated
[List each component with file path]
### Technical Changes
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
- **Props:** [List prop transformations]
- **Structure:** Implemented proper [NewComponent] component hierarchy
- **Styling:** [Describe styling changes]
- **Tests:** Updated all test mocks and expectations
### Migration Pattern
```typescript
// Before
<[DeprecatedComponent] [oldProps]>
[oldStructure]
</[DeprecatedComponent]>
// After
<[NewComponent] [newProps]>
[newStructure]
</[NewComponent]>
```
### Testing
- ✅ All existing tests updated and passing
- ✅ Component functionality preserved
- ✅ UI/UX behavior maintained
### How to Test This PR
1. **Functional Testing:**
- Navigate to each migrated component's usage
- Verify [component] opens and closes correctly
- Test all interactive elements within [components]
- Confirm styling and layout are preserved
2. **Automated Testing:**
```bash
Node package manager test
```
3. **Visual Testing:**
- Check that all [components] maintain proper styling
- Verify responsive behavior
- Test keyboard navigation and accessibility
### Breaking Changes
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
### Notes
- [Any excluded components] were preserved as they already use [NewComponent] internally
- All form validation and complex state management preserved
- Enhanced code quality with better imports and formatting
```
## Special Considerations
### Excluded Components
- **DO NOT MIGRATE** components specified by user as exclusions
- They may already use the new component internally or have other reasons
- Inform user these are skipped and why
### Complex Components
- Preserve all existing functionality (forms, validation, state management)
- Maintain prop interfaces
- Keep all event handlers and callbacks
- Preserve accessibility features
### Test Coverage
- Ensure all new component parts are mocked when used
- Mock all new component parts that appear in the component
- Update test IDs from old component to new component
- Maintain all existing test scenarios
### Error Handling
- If tests fail after 3 iterations, stop and ask user for guidance
- If component is too complex, ask user for specific guidance
- If unsure about functionality preservation, ask for clarification
### Migration Patterns
- Always ask user for specific migration patterns before starting
- Confirm import structures, prop mappings, and component hierarchies
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
## Success Criteria
- All deprecated components successfully migrated to new components
- All tests passing
- No functionality lost
- Code quality maintained or improved
- User approval on each component
- Successful git commits for each migration
- Comprehensive PR report generated
## Usage Examples
- "migrate Modal to Dialog"
- "migrate Button to NewButton"
- "migrate Card to ModernCard"
- "component migration" (will prompt for details)
@@ -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.
+6 -1
View File
@@ -90,7 +90,7 @@ When testing hooks that use React Context:
vi.mocked(useResponseFilter).mockReturnValue({ vi.mocked(useResponseFilter).mockReturnValue({
selectedFilter: { selectedFilter: {
filter: [], filter: [],
responseStatus: "all", onlyComplete: false,
}, },
setSelectedFilter: vi.fn(), setSelectedFilter: vi.fn(),
selectedOptions: { selectedOptions: {
@@ -291,6 +291,11 @@ test("handles different modes", async () => {
expect(vi.mocked(regularApi)).toHaveBeenCalled(); expect(vi.mocked(regularApi)).toHaveBeenCalled();
}); });
// Test sharing mode
vi.mocked(useParams).mockReturnValue({
surveyId: "123",
sharingKey: "share-123"
});
rerender(); rerender();
await waitFor(() => { await waitFor(() => {
+5 -3
View File
@@ -189,11 +189,15 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY= UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided) # The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this) # The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
# REDIS_HTTP_URL: # REDIS_HTTP_URL:
# The below is used for Rate Limiting for management API
UNKEY_ROOT_KEY=
# INTERCOM_APP_ID= # INTERCOM_APP_ID=
# INTERCOM_SECRET_KEY= # INTERCOM_SECRET_KEY=
@@ -206,8 +210,6 @@ REDIS_URL=redis://localhost:6379
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin. # The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
# It's used automatically by Sentry during the build for authentication when uploading source maps. # It's used automatically by Sentry during the build for authentication when uploading source maps.
# SENTRY_AUTH_TOKEN= # SENTRY_AUTH_TOKEN=
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
# SENTRY_ENVIRONMENT=
# Configure the minimum role for user management from UI(owner, manager, disabled) # Configure the minimum role for user management from UI(owner, manager, disabled)
# USER_MANAGEMENT_MINIMUM_ROLE="manager" # USER_MANAGEMENT_MINIMUM_ROLE="manager"
@@ -215,7 +217,7 @@ REDIS_URL=redis://localhost:6379
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours) # Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
# SESSION_MAX_AGE=86400 # SESSION_MAX_AGE=86400
# Audit logs options. Default 0. # Audit logs options. Requires REDIS_URL env varibale. Default 0.
# AUDIT_LOG_ENABLED=0 # AUDIT_LOG_ENABLED=0
# If the ip should be added in the log or not. Default 0 # If the ip should be added in the log or not. Default 0
# AUDIT_LOG_GET_USER_IP=0 # AUDIT_LOG_GET_USER_IP=0
-1
View File
@@ -1,7 +1,6 @@
name: Bug report name: Bug report
description: "Found a bug? Please fill out the sections below. \U0001F44D" description: "Found a bug? Please fill out the sections below. \U0001F44D"
type: bug type: bug
projects: "formbricks/8"
labels: ["bug"] labels: ["bug"]
body: body:
- type: textarea - type: textarea
+1 -1
View File
@@ -1,4 +1,4 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links: contact_links:
- name: Questions - name: Questions
url: https://github.com/formbricks/formbricks/discussions url: https://github.com/formbricks/formbricks/discussions
@@ -1,7 +1,6 @@
name: Feature request name: Feature request
description: "Suggest an idea for this project \U0001F680" description: "Suggest an idea for this project \U0001F680"
type: feature type: feature
projects: "formbricks/21"
body: body:
- type: textarea - type: textarea
id: problem-description id: problem-description
+11
View File
@@ -0,0 +1,11 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true
+1 -3
View File
@@ -62,12 +62,10 @@ runs:
shell: bash shell: bash
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env - name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
env:
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
run: | run: |
RANDOM_KEY=$(openssl rand -hex 32) RANDOM_KEY=$(openssl rand -hex 32)
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env 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 shell: bash
- run: | - run: |
@@ -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),
});
}
-168
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 }}
+1 -3
View File
@@ -6,14 +6,12 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
permissions:
contents: read
jobs: jobs:
chromatic: chromatic:
name: Run Chromatic name: Run Chromatic
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read
packages: write packages: write
id-token: write id-token: write
actions: read actions: read
+27
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
+14 -21
View File
@@ -17,8 +17,8 @@ on:
required: true required: true
type: choice type: choice
options: options:
- staging - stage
- production - prod
workflow_call: workflow_call:
inputs: inputs:
VERSION: VERSION:
@@ -37,27 +37,21 @@ on:
permissions: permissions:
id-token: write id-token: write
contents: read contents: write
jobs: jobs:
helmfile-deploy: helmfile-deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4.2.2
- name: Tailscale - name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3 uses: tailscale/github-action@v3
with: with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
tags: tag:github tags: tag:github
args: --accept-routes
- name: Configure AWS Credentials - name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0 uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
@@ -71,9 +65,9 @@ jobs:
env: env:
AWS_REGION: eu-central-1 AWS_REGION: eu-central-1
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4 - uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Production name: Deploy Formbricks Cloud Prod
if: inputs.ENVIRONMENT == 'production' if: inputs.ENVIRONMENT == 'prod'
env: env:
VERSION: ${{ inputs.VERSION }} VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }} REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -89,9 +83,9 @@ jobs:
helmfile-auto-init: "false" helmfile-auto-init: "false"
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4 - uses: helmfile/helmfile-action@v2
name: Deploy Formbricks Cloud Staging name: Deploy Formbricks Cloud Stage
if: inputs.ENVIRONMENT == 'staging' if: inputs.ENVIRONMENT == 'stage'
env: env:
VERSION: ${{ inputs.VERSION }} VERSION: ${{ inputs.VERSION }}
REPOSITORY: ${{ inputs.REPOSITORY }} REPOSITORY: ${{ inputs.REPOSITORY }}
@@ -107,20 +101,19 @@ jobs:
helmfile-workdirectory: infra/formbricks-cloud-helm helmfile-workdirectory: infra/formbricks-cloud-helm
- name: Purge Cloudflare Cache - name: Purge Cloudflare Cache
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }} if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
env: env:
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }} CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
run: | run: |
# Set hostname based on environment # Set hostname based on environment
if [[ "$ENVIRONMENT" == "production" ]]; then if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
PURGE_HOST="app.formbricks.com" PURGE_HOST="app.formbricks.com"
else else
PURGE_HOST="stage.app.formbricks.com" PURGE_HOST="stage.app.formbricks.com"
fi 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 # Prepare JSON payload for selective cache purge
json_payload=$(cat << EOF json_payload=$(cat << EOF
+61 -64
View File
@@ -39,68 +39,42 @@ jobs:
--health-retries 5 --health-retries 5
steps: 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 - name: Checkout Repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 uses: docker/setup-buildx-action@v3
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 uses: docker/build-push-action@v6
env:
GITHUB_SHA: ${{ github.sha }}
with: with:
context: . context: .
file: ./apps/web/Dockerfile file: ./apps/web/Dockerfile
push: false push: false
load: true load: true
tags: formbricks-test:${{ env.GITHUB_SHA }} tags: formbricks-test:${{ github.sha }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
- name: Verify and Initialize PostgreSQL - name: Verify PostgreSQL Connection
run: | run: |
echo "Verifying PostgreSQL connection..." echo "Verifying PostgreSQL connection..."
# Install PostgreSQL client to test connection # Install PostgreSQL client to test connection
sudo apt-get update && sudo apt-get install -y postgresql-client sudo apt-get update && sudo apt-get install -y postgresql-client
# Test connection using psql with timeout and proper error handling # Test connection using psql
echo "Testing PostgreSQL connection with 30 second timeout..." PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
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
# Show network configuration # Show network configuration
echo "Network configuration:" echo "Network configuration:"
ip addr show
netstat -tulpn | grep 5432 || echo "No process listening on port 5432" netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
- name: Test Docker Image with Health Check - name: Test Docker Image with Health Check
shell: bash shell: bash
env:
GITHUB_SHA: ${{ github.sha }}
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
run: | run: |
echo "🧪 Testing if the Docker image starts correctly..." echo "🧪 Testing if the Docker image starts correctly..."
@@ -112,12 +86,29 @@ jobs:
$DOCKER_RUN_ARGS \ $DOCKER_RUN_ARGS \
-p 3000:3000 \ -p 3000:3000 \
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \ -e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \ -e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
-d "formbricks-test:$GITHUB_SHA" -d formbricks-test:${{ github.sha }}
# Start health check polling immediately (every 5 seconds for up to 5 minutes) # Give it more time to start up
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..." echo "Waiting 45 seconds for application to start..."
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes 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 RETRY_COUNT=0
HEALTH_CHECK_SUCCESS=false HEALTH_CHECK_SUCCESS=false
@@ -125,32 +116,38 @@ jobs:
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
RETRY_COUNT=$((RETRY_COUNT + 1)) RETRY_COUNT=$((RETRY_COUNT + 1))
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
# Check if container is still running
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then # Show container logs before each attempt to help debugging
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!" if [ $RETRY_COUNT -gt 1 ]; then
echo "📋 Container logs:" echo "📋 Current container logs:"
docker logs formbricks-test docker logs --tail 20 formbricks-test
exit 1
fi fi
# Show progress and diagnostic info every 12 attempts (1 minute intervals) # Get detailed curl output for debugging
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..." CURL_EXIT_CODE=$?
echo "📋 Recent container logs:"
docker logs --tail 10 formbricks-test 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 fi
# Try health endpoint with shorter timeout for faster polling echo "Waiting 15 seconds before next attempt..."
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx) sleep 15
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
done done
# Show full container logs for debugging # Show full container logs for debugging
@@ -163,7 +160,7 @@ jobs:
# Exit with failure if health check did not succeed # Exit with failure if health check did not succeed
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then 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 exit 1
fi fi
@@ -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"
-7
View File
@@ -89,7 +89,6 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env echo "" >> .env
echo "E2E_TESTING=1" >> .env echo "E2E_TESTING=1" >> .env
shell: bash shell: bash
@@ -103,12 +102,6 @@ jobs:
# pnpm prisma migrate deploy # pnpm prisma migrate deploy
pnpm db:migrate:dev pnpm db:migrate:dev
- name: Run Rate Limiter Load Tests
run: |
echo "Running rate limiter load tests with Redis/Valkey..."
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Check for Enterprise License - name: Check for Enterprise License
run: | run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-) LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
+7 -21
View File
@@ -1,29 +1,20 @@
name: Build, release & deploy Formbricks images name: Build, release & deploy Formbricks images
on: on:
release: workflow_dispatch:
types: [published] push:
tags:
permissions: - "v*"
contents: read
jobs: jobs:
docker-build: docker-build:
name: Build & release docker image name: Build & release stable docker image
permissions: if: startsWith(github.ref, 'refs/tags/v')
contents: read
packages: write
id-token: write
uses: ./.github/workflows/release-docker-github.yml uses: ./.github/workflows/release-docker-github.yml
secrets: inherit secrets: inherit
with:
IS_PRERELEASE: ${{ github.event.release.prerelease }}
helm-chart-release: helm-chart-release:
name: Release Helm Chart name: Release Helm Chart
permissions:
contents: read
packages: write
uses: ./.github/workflows/release-helm-chart.yml uses: ./.github/workflows/release-helm-chart.yml
secrets: inherit secrets: inherit
needs: needs:
@@ -33,9 +24,6 @@ jobs:
deploy-formbricks-cloud: deploy-formbricks-cloud:
name: Deploy Helm Chart to Formbricks Cloud name: Deploy Helm Chart to Formbricks Cloud
permissions:
contents: read
id-token: write
secrets: inherit secrets: inherit
uses: ./.github/workflows/deploy-formbricks-cloud.yml uses: ./.github/workflows/deploy-formbricks-cloud.yml
needs: needs:
@@ -43,6 +31,4 @@ jobs:
- helm-chart-release - helm-chart-release
with: with:
VERSION: v${{ needs.docker-build.outputs.VERSION }} VERSION: v${{ needs.docker-build.outputs.VERSION }}
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }} ENVIRONMENT: "prod"
+2
View File
@@ -10,6 +10,8 @@ permissions:
on: on:
pull_request: pull_request:
branches:
- main
merge_group: merge_group:
workflow_dispatch: workflow_dispatch:
@@ -29,8 +29,6 @@ jobs:
# with sigstore/fulcio when running outside of PRs. # with sigstore/fulcio when running outside of PRs.
id-token: write id-token: write
steps: steps:
- name: Harden the runner (Audit all outbound calls) - name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0 uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
@@ -39,50 +37,6 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 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 - name: Set up Depot CLI
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0 uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
@@ -110,9 +64,6 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=raw,value=${{ env.VERSION }}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
@@ -131,9 +82,6 @@ jobs:
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # This will only write to the public Rekor transparency log when the Docker
@@ -148,4 +96,4 @@ jobs:
DIGEST: ${{ steps.build-and-push.outputs.digest }} DIGEST: ${{ steps.build-and-push.outputs.digest }}
# This step uses the identity token to provision an ephemeral certificate # This step uses the identity token to provision an ephemeral certificate
# against the sigstore community Fulcio instance. # against the sigstore community Fulcio instance.
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}" run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
+1 -31
View File
@@ -7,12 +7,6 @@ name: Docker Release to Github
on: on:
workflow_call: workflow_call:
inputs:
IS_PRERELEASE:
description: "Whether this is a prerelease (affects latest tag)"
required: false
type: boolean
default: false
outputs: outputs:
VERSION: VERSION:
description: release version description: release version
@@ -26,9 +20,6 @@ env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }} TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
permissions:
contents: read
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -54,23 +45,10 @@ jobs:
- name: Get Release Tag - name: Get Release Tag
id: extract_release_tag id: extract_release_tag
run: | run: |
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3) TAG=${{ github.ref }}
TAG="$GITHUB_REF"
TAG=${TAG#refs/tags/v} TAG=${TAG#refs/tags/v}
# 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 "RELEASE_TAG=$TAG" >> $GITHUB_ENV
echo "VERSION=$TAG" >> $GITHUB_OUTPUT echo "VERSION=$TAG" >> $GITHUB_OUTPUT
echo "Using tag-based version: $TAG"
- name: Update package.json version - name: Update package.json version
run: | run: |
@@ -103,13 +81,6 @@ jobs:
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Default semver tags (version, major.minor, major)
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
# Only tag as 'latest' for stable releases (not prereleases)
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
# Build and push Docker image with Buildx (don't push on PR) # Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action # https://github.com/docker/build-push-action
@@ -128,7 +99,6 @@ jobs:
secrets: | secrets: |
database_url=${{ secrets.DUMMY_DATABASE_URL }} database_url=${{ secrets.DUMMY_DATABASE_URL }}
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }} encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
# Sign the resulting Docker image digest except on PRs. # Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker # This will only write to the public Rekor transparency log when the Docker
+6 -24
View File
@@ -26,23 +26,8 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Validate input version - name: Extract release version
env: run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_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: Set up Helm - name: Set up Helm
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5 uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
@@ -50,18 +35,15 @@ jobs:
version: latest version: latest
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
env: run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
- name: Install YQ - name: Install YQ
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1 uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
- name: Update Chart.yaml with new version - name: Update Chart.yaml with new version
run: | run: |
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
- name: Package Helm chart - name: Package Helm chart
run: | run: |
@@ -69,4 +51,4 @@ jobs:
- name: Push Helm chart to GitHub Container Registry - name: Push Helm chart to GitHub Container Registry
run: | 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
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
@@ -56,3 +56,11 @@ jobs:
``` ```
${{ steps.lint_pr_title.outputs.error_message }} ${{ 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! 🙏
-1
View File
@@ -43,7 +43,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage - name: Run tests with coverage
run: | run: |
@@ -14,14 +14,12 @@ on:
paths: paths:
- "infra/terraform/**" - "infra/terraform/**"
permissions:
contents: read
jobs: jobs:
terraform: terraform:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
id-token: write id-token: write
contents: read
pull-requests: write pull-requests: write
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -35,7 +33,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Tailscale - name: Tailscale
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3 uses: tailscale/github-action@v3
with: with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
-1
View File
@@ -41,7 +41,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test - name: Test
run: pnpm test run: pnpm test
+1 -9
View File
@@ -27,18 +27,10 @@ jobs:
- name: Get source branch name - name: Get source branch name
id: branch-name id: branch-name
env:
RAW_BRANCH: ${{ github.head_ref }}
run: | 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') 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 # Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks # This prevents environment variable injection attacks
@@ -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. 😊
-12
View File
@@ -31,18 +31,6 @@
{ {
"language": "pt-PT", "language": "pt-PT",
"path": "./apps/web/locales/pt-PT.json" "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" "forceMode": "OVERRIDE"
+11 -1
View File
@@ -14,7 +14,17 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
## 🛠 Crafting Pull Requests ## 🛠 Crafting Pull Requests
For the time being, we don't have the capacity to properly facilitate community contributions. It's a lot of engineering attention often spent on issues which don't follow our prioritization, so we've decided to only facilitate community code contributions in rare exceptions in the coming months. Ready to dive into the code and make a real impact? Here's your path:
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
1. **Tweak and Transform:** Work your coding magic and apply your changes.
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
## 🚀 Aspiring Features ## 🚀 Aspiring Features
+1 -2
View File
@@ -21,7 +21,6 @@ The Open Source Qualtrics Alternative
<p align="center"> <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://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://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://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> <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>
@@ -193,7 +192,7 @@ Here are a few options:
- Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap. - Upvote issues with 👍 reaction so we know what the demand for a particular issue is to prioritize it within the roadmap.
- Note: For the time being, we can only facilitate code contributions as an exception. Please check out [our contribution guide](https://formbricks.com/docs/developer-docs/contributing/get-started) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
## All Thanks To Our Contributors ## All Thanks To Our Contributors
+4 -6
View File
@@ -1,25 +1,23 @@
import type { StorybookConfig } from "@storybook/react-vite"; import type { StorybookConfig } from "@storybook/react-vite";
import { createRequire } from "module";
import { dirname, join } from "path"; import { dirname, join } from "path";
const require = createRequire(import.meta.url);
/** /**
* This function is used to resolve the absolute path of a package. * This function is used to resolve the absolute path of a package.
* It is needed in projects that use Yarn PnP or are set up within a monorepo. * It is needed in projects that use Yarn PnP or are set up within a monorepo.
*/ */
function getAbsolutePath(value: string): any { const getAbsolutePath = (value: string) => {
return dirname(require.resolve(join(value, "package.json"))); return dirname(require.resolve(join(value, "package.json")));
} };
const config: StorybookConfig = { const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"], stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
addons: [ addons: [
getAbsolutePath("@storybook/addon-onboarding"), getAbsolutePath("@storybook/addon-onboarding"),
getAbsolutePath("@storybook/addon-links"), getAbsolutePath("@storybook/addon-links"),
getAbsolutePath("@storybook/addon-essentials"),
getAbsolutePath("@chromatic-com/storybook"), getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("@storybook/addon-interactions"),
getAbsolutePath("@storybook/addon-a11y"), getAbsolutePath("@storybook/addon-a11y"),
getAbsolutePath("@storybook/addon-docs"),
], ],
framework: { framework: {
name: getAbsolutePath("@storybook/react-vite"), name: getAbsolutePath("@storybook/react-vite"),
+1 -29
View File
@@ -1,32 +1,5 @@
import type { Preview } from "@storybook/react-vite"; import type { Preview } from "@storybook/react";
import { TolgeeProvider } from "@tolgee/react";
import React from "react";
// Import translation data for Storybook
import enUSTranslations from "../../web/locales/en-US.json";
import "../../web/modules/ui/globals.css"; import "../../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 = { const preview: Preview = {
parameters: { parameters: {
@@ -37,7 +10,6 @@ const preview: Preview = {
}, },
}, },
}, },
decorators: [withTolgee],
}; };
export default preview; export default preview;
+13 -9
View File
@@ -14,19 +14,23 @@
"eslint-plugin-react-refresh": "0.4.20" "eslint-plugin-react-refresh": "0.4.20"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^4.0.1", "@chromatic-com/storybook": "3.2.6",
"@storybook/addon-a11y": "9.0.15", "@storybook/addon-a11y": "8.6.12",
"@storybook/addon-links": "9.0.15", "@storybook/addon-essentials": "8.6.12",
"@storybook/addon-onboarding": "9.0.15", "@storybook/addon-interactions": "8.6.12",
"@storybook/react-vite": "9.0.15", "@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/eslint-plugin": "8.32.0",
"@typescript-eslint/parser": "8.32.0", "@typescript-eslint/parser": "8.32.0",
"@vitejs/plugin-react": "4.4.1", "@vitejs/plugin-react": "4.4.1",
"esbuild": "0.25.4", "esbuild": "0.25.4",
"eslint-plugin-storybook": "9.0.15", "eslint-plugin-storybook": "0.12.0",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"storybook": "9.0.15", "storybook": "8.6.12",
"vite": "6.3.5", "vite": "6.3.5"
"@storybook/addon-docs": "9.0.15"
} }
} }
+1 -1
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 Accessibility from "./assets/accessibility.png";
import AddonLibrary from "./assets/addon-library.png"; import AddonLibrary from "./assets/addon-library.png";
+43 -21
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine3.22 AS base FROM node:22-alpine3.21 AS base
# #
## step 1: Prune monorepo ## step 1: Prune monorepo
@@ -25,18 +25,26 @@ RUN corepack prepare pnpm@9.15.9 --activate
# Install necessary build tools and compilers # Install necessary build tools and compilers
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3 RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
# Copy the secrets handling script # BuildKit secret handling without hardcoded fallback values
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh # This approach relies entirely on secrets passed from GitHub Actions
RUN chmod +x /tmp/read-secrets.sh RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
echo 'else' >> /tmp/read-secrets.sh && \
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
echo 'fi' >> /tmp/read-secrets.sh && \
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
chmod +x /tmp/read-secrets.sh
# Increase Node.js memory limit as a regular build argument # 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} 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 # Set the working directory
WORKDIR /app WORKDIR /app
@@ -54,14 +62,10 @@ RUN touch apps/web/.env
# Install the dependencies # Install the dependencies
RUN pnpm install --ignore-scripts RUN pnpm install --ignore-scripts
# Build the database package first
RUN pnpm build --filter=@formbricks/database
# Build the project using our secret reader script # Build the project using our secret reader script
# This mounts the secrets only during this build step without storing them in layers # This mounts the secrets only during this build step without storing them in layers
RUN --mount=type=secret,id=database_url \ RUN --mount=type=secret,id=database_url \
--mount=type=secret,id=encryption_key \ --mount=type=secret,id=encryption_key \
--mount=type=secret,id=sentry_auth_token \
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web... /tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
# Extract Prisma version # Extract Prisma version
@@ -102,8 +106,20 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
COPY --from=installer /app/packages/database/dist ./packages/database/dist COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/migration ./packages/database/migration
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
COPY --from=installer /app/packages/database/src ./packages/database/src
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
@@ -126,14 +142,12 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
COPY --from=installer /app/node_modules/zod ./node_modules/zod COPY --from=installer /app/node_modules/zod ./node_modules/zod
RUN chmod -R 755 ./node_modules/zod RUN chmod -R 755 ./node_modules/zod
RUN npm install --ignore-scripts -g tsx typescript pino-pretty
RUN npm install -g prisma RUN npm install -g prisma
# Create a startup script to handle the conditional logic
COPY --from=installer /app/apps/web/scripts/docker/next-start.sh /home/nextjs/start.sh
RUN chown nextjs:nextjs /home/nextjs/start.sh && chmod +x /home/nextjs/start.sh
EXPOSE 3000 EXPOSE 3000
ENV HOSTNAME="0.0.0.0" ENV HOSTNAME "0.0.0.0"
ENV NODE_ENV="production"
USER nextjs USER nextjs
# Prepare volume for uploads # Prepare volume for uploads
@@ -144,4 +158,12 @@ VOLUME /home/nextjs/apps/web/uploads/
RUN mkdir -p /home/nextjs/apps/web/saml-connection RUN mkdir -p /home/nextjs/apps/web/saml-connection
VOLUME /home/nextjs/apps/web/saml-connection VOLUME /home/nextjs/apps/web/saml-connection
CMD ["/home/nextjs/start.sh"] CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
echo "Starting cron jobs..."; \
supercronic -quiet /app/docker/cronjobs & \
else \
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
fi; \
(cd packages/database && npm run db:migrate:deploy) && \
(cd packages/database && npm run db:create-saml-database:deploy) && \
exec node apps/web/server.js
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -45,11 +45,22 @@ afterEach(() => {
}); });
describe("LandingSidebar component", () => { 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 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", () => { 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 // Formbricks logo
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument(); expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
@@ -60,7 +71,14 @@ describe("LandingSidebar component", () => {
}); });
test("clicking logout triggers signOut", async () => { 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 // Open user dropdown by clicking on avatar trigger
const trigger = screen.getByTestId("avatar").parentElement; const trigger = screen.getByTestId("avatar").parentElement;
@@ -76,7 +94,6 @@ describe("LandingSidebar component", () => {
organizationId: "o1", organizationId: "o1",
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}); });
}); });
@@ -10,27 +10,48 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react"; 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 Image from "next/image";
import Link from "next/link"; 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 { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
interface LandingSidebarProps { interface LandingSidebarProps {
isMultiOrgEnabled: boolean;
user: TUser; user: TUser;
organization: TOrganization; 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 [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
const { t } = useTranslate(); const { t } = useTranslate();
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const router = useRouter();
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
router.push(`/organizations/${organizationId}/`);
};
const dropdownNavigation = [ const dropdownNavigation = [
{ {
label: t("common.documentation"), 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 ( return (
<aside <aside
className={cn( className={cn(
@@ -52,28 +80,27 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
<DropdownMenuTrigger <DropdownMenuTrigger
asChild asChild
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<button <div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
type="button" <ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")} <>
aria-haspopup="menu"> <div>
<ProfileAvatar userId={user.id} /> <p
<div className="grow overflow-hidden"> title={user?.email}
<p className={cn(
title={user?.email} "ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
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>
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>} <p
</p> title={capitalizeFirstLetter(organization?.name)}
<p className="max-w-28 truncate text-sm text-slate-500">
title={capitalizeFirstLetter(organization?.name)} {capitalizeFirstLetter(organization?.name)}
className="truncate text-sm text-slate-500"> </p>
{capitalizeFirstLetter(organization?.name)} </div>
</p> <ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
</div> </>
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} /> </div>
</button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -85,13 +112,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
{/* Dropdown Items */} {/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map((link) => (
<Link <Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
key={link.href}
id={link.href}
href={link.href}
target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
className="flex w-full items-center">
<DropdownMenuItem> <DropdownMenuItem>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} /> <link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
{link.label} {link.label}
@@ -100,6 +121,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
))} ))}
{/* Logout */} {/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
await signOutWithAudit({ await signOutWithAudit({
@@ -108,12 +130,50 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id, organizationId: organization.id,
redirect: true, redirect: true,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")} {t("common.logout")}
</DropdownMenuItem> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -1,4 +1,3 @@
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getOrganizationsByUserId } from "@/lib/organization/service"; import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getOrganizationAuth } from "@/modules/organization/lib/utils"; import { getOrganizationAuth } from "@/modules/organization/lib/utils";
@@ -16,7 +15,6 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
isPendingDowngrade: false, isPendingDowngrade: false,
fallbackLevel: "live", fallbackLevel: "live",
}), }),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
})); }));
vi.mock("@/lib/constants", () => ({ vi.mock("@/lib/constants", () => ({
@@ -99,36 +97,20 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com", OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256", OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, 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", () => ({ vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
LandingSidebar: () => <div data-testid="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("@/modules/organization/lib/utils");
vi.mock("@/lib/user/service"); vi.mock("@/lib/user/service");
vi.mock("@/lib/organization/service"); vi.mock("@/lib/organization/service");
vi.mock("@/lib/membership/service");
vi.mock("@/tolgee/server"); vi.mock("@/tolgee/server");
vi.mock("next/navigation", () => ({ vi.mock("next/navigation", () => ({
redirect: vi.fn(() => "REDIRECT_STUB"), redirect: vi.fn(() => "REDIRECT_STUB"),
notFound: vi.fn(() => "NOT_FOUND_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 // Mock the React cache function
@@ -160,7 +142,6 @@ describe("Page component", () => {
isPendingDowngrade: false, isPendingDowngrade: false,
fallbackLevel: "live", fallbackLevel: "live",
}), }),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
})); }));
const { default: Page } = await import("./page"); const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } }); const result = await Page({ params: { organizationId: "org1" } });
@@ -182,7 +163,6 @@ describe("Page component", () => {
isPendingDowngrade: false, isPendingDowngrade: false,
fallbackLevel: "live", fallbackLevel: "live",
}), }),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
})); }));
const { default: Page } = await import("./page"); const { default: Page } = await import("./page");
const result = await Page({ params: { organizationId: "org1" } }); const result = await Page({ params: { organizationId: "org1" } });
@@ -193,16 +173,10 @@ describe("Page component", () => {
test("renders header and sidebar for authenticated user", async () => { test("renders header and sidebar for authenticated user", async () => {
vi.mocked(getOrganizationAuth).mockResolvedValue({ vi.mocked(getOrganizationAuth).mockResolvedValue({
session: { user: { id: "user1" } }, session: { user: { id: "user1" } },
organization: { id: "org1", billing: { plan: "free" } }, organization: { id: "org1" },
} as any); } as any);
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } 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(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) => vi.mocked(getTranslate).mockResolvedValue((props: any) =>
typeof props === "string" ? props : props.key || "" typeof props === "string" ? props : props.key || ""
); );
@@ -214,13 +188,11 @@ describe("Page component", () => {
isPendingDowngrade: false, isPendingDowngrade: false,
fallbackLevel: "live", fallbackLevel: "live",
}), }),
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
})); }));
const { default: Page } = await import("./page"); const { default: Page } = await import("./page");
const element = await Page({ params: { organizationId: "org1" } }); const element = await Page({ params: { organizationId: "org1" } });
render(element as React.ReactElement); render(element as React.ReactElement);
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument(); 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_title")).toBeInTheDocument();
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument(); expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
}); });
@@ -1,11 +1,7 @@
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar"; 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 { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
import { getTranslate } from "@/tolgee/server"; import { getTranslate } from "@/tolgee/server";
@@ -26,38 +22,24 @@ const Page = async (props) => {
const organizations = await getOrganizationsByUserId(session.user.id); 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 isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const { isMember } = getAccessFlags(membership?.role);
return ( return (
<div className="flex min-h-full min-w-full flex-row"> <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-1">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col items-center justify-center space-y-12">
<div className="p-6"> <Header
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */} title={t("organizations.landing.no_projects_warning_title")}
<ProjectAndOrgSwitch subtitle={t("organizations.landing.no_projects_warning_subtitle")}
currentOrganizationId={organization.id} />
organizations={organizations}
projects={[]}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
isAccessControlAllowed={false}
isMember={isMember}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
<Header
title={t("organizations.landing.no_projects_warning_title")}
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url", WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false, IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
industry: "ind", industry: "ind",
defaultBrandColor: "#fff", defaultBrandColor: "#fff",
organizationTeams: [], organizationTeams: [],
isAccessControlAllowed: false, canDoRoleManagement: false,
userProjectsCount: 0, userProjectsCount: 0,
} as any; } as any;
@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
industry: TProjectConfigIndustry; industry: TProjectConfigIndustry;
defaultBrandColor: string; defaultBrandColor: string;
organizationTeams: TOrganizationTeam[]; organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean; canDoRoleManagement: boolean;
userProjectsCount: number; userProjectsCount: number;
} }
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
industry, industry,
defaultBrandColor, defaultBrandColor,
organizationTeams, organizationTeams,
isAccessControlAllowed = false, canDoRoleManagement = false,
userProjectsCount, userProjectsCount,
}: ProjectSettingsProps) => { }: ProjectSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false); const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
)} )}
/> />
{isAccessControlAllowed && userProjectsCount > 0 && ( {canDoRoleManagement && userProjectsCount > 0 && (
<FormField <FormField
control={form.control} control={form.control}
name="teamIds" name="teamIds"
@@ -1,6 +1,6 @@
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding"; import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { getUserProjects } from "@/lib/project/service"; 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
// Mocks before component import // Mocks before component import
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() })); vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
vi.mock("@/lib/project/service", () => ({ getUserProjects: 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("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) })); vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null 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"); await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
}); });
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] 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 }); const element = await Page({ params, searchParams });
render(element as React.ReactElement); render(element as React.ReactElement);
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
} as any); } as any);
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any); vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] 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 }); const element = await Page({ params, searchParams });
render(element as React.ReactElement); render(element as React.ReactElement);
@@ -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 { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants"; import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getUserProjects } from "@/lib/project/service"; 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header"; import { Header } from "@/modules/ui/components/header";
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const organizationTeams = await getTeamsByOrganizationId(params.organizationId); const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
if (!organizationTeams) { if (!organizationTeams) {
throw new Error(t("common.organization_teams_not_found")); throw new Error(t("common.organization_teams_not_found"));
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
industry={industry} industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR} defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams} organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed} canDoRoleManagement={canDoRoleManagement}
userProjectsCount={projects.length} userProjectsCount={projects.length}
/> />
{projects.length >= 1 && ( {projects.length >= 1 && (
@@ -18,6 +18,11 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
DevEnvironmentBanner: ({ environment }: any) => (
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
),
}));
// Mocks for dependencies // Mocks for dependencies
vi.mock("@/modules/environments/lib/utils", () => ({ vi.mock("@/modules/environments/lib/utils", () => ({
@@ -53,6 +58,7 @@ describe("SurveyEditorEnvironmentLayout", () => {
render(result); render(result);
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1"); expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content"); expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
}); });
@@ -1,5 +1,6 @@
import { getEnvironment } from "@/lib/environment/service"; import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils"; 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 { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
@@ -31,6 +32,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
user={user} user={user}
organization={organization}> organization={organization}>
<div className="flex h-screen flex-col"> <div className="flex h-screen flex-col">
<DevEnvironmentBanner environment={environment} />
<div className="h-full overflow-y-auto bg-slate-50">{children}</div> <div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div> </div>
</EnvironmentIdBaseLayout> </EnvironmentIdBaseLayout>
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true, IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1, AUDIT_LOG_ENABLED: 1,
REDIS_URL: undefined, REDIS_URL: "redis://localhost:6379",
})); }));
vi.mock("@/lib/env", () => ({ vi.mock("@/lib/env", () => ({
@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context"; import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler"; import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { import {
getAccessControlPermission,
getOrganizationProjectsLimit, getOrganizationProjectsLimit,
getRoleManagementPermission,
} from "@/modules/ee/license-check/lib/utils"; } from "@/modules/ee/license-check/lib/utils";
import { createProject } from "@/modules/projects/settings/lib/project"; import { createProject } from "@/modules/projects/settings/lib/project";
import { z } from "zod"; import { z } from "zod";
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
} }
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) { 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"); throw new OperationNotAllowedError("You do not have permission to manage roles");
} }
} }
@@ -71,6 +71,10 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
alert: { alert: {
...user.notificationSettings?.alert, ...user.notificationSettings?.alert,
}, },
weeklySummary: {
...user.notificationSettings?.weeklySummary,
[project.id]: true,
},
}; };
await updateUser(user.id, { await updateUser(user.id, {
@@ -24,17 +24,14 @@ export const ActionClassesTable = ({
otherEnvActionClasses, otherEnvActionClasses,
otherEnvironment, otherEnvironment,
}: ActionClassesTableProps) => { }: ActionClassesTableProps) => {
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false); const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
const [activeActionClass, setActiveActionClass] = useState<TActionClass>(); const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
const handleOpenActionDetailModalClick = ( const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
e: React.MouseEvent<HTMLButtonElement>,
actionClass: TActionClass
) => {
e.preventDefault(); e.preventDefault();
setActiveActionClass(actionClass); setActiveActionClass(actionClass);
setIsActionDetailModalOpen(true); setActionDetailModalOpen(true);
}; };
return ( return (
@@ -45,7 +42,7 @@ export const ActionClassesTable = ({
{actionClasses.length > 0 ? ( {actionClasses.length > 0 ? (
actionClasses.map((actionClass, index) => ( actionClasses.map((actionClass, index) => (
<button <button
onClick={(e: React.MouseEvent<HTMLButtonElement>) => { onClick={(e) => {
handleOpenActionDetailModalClick(e, actionClass); handleOpenActionDetailModalClick(e, actionClass);
}} }}
className="w-full" className="w-full"
@@ -66,7 +63,7 @@ export const ActionClassesTable = ({
environmentId={environmentId} environmentId={environmentId}
environment={environment} environment={environment}
open={isActionDetailModalOpen} open={isActionDetailModalOpen}
setOpen={setIsActionDetailModalOpen} setOpen={setActionDetailModalOpen}
actionClasses={actionClasses} actionClasses={actionClasses}
actionClass={activeActionClass} actionClass={activeActionClass}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
@@ -1,5 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react"; import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
import userEvent from "@testing-library/user-event"; import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { TActionClass } from "@formbricks/types/action-classes"; import { TActionClass } from "@formbricks/types/action-classes";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
@@ -8,40 +8,23 @@ import { ActionDetailModal } from "./ActionDetailModal";
// Import mocked components // Import mocked components
import { ActionSettingsTab } from "./ActionSettingsTab"; import { ActionSettingsTab } from "./ActionSettingsTab";
// Mock the Dialog components // Mock child components
vi.mock("@/modules/ui/components/dialog", () => ({ vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
Dialog: ({ ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
open, <div data-testid="modal-with-tabs">
onOpenChange, <span data-testid="modal-label">{label}</span>
children, <span data-testid="modal-description">{description}</span>
}: { <span data-testid="modal-open">{open.toString()}</span>
open: boolean; <button onClick={() => setOpen(false)}>Close</button>
onOpenChange: (open: boolean) => void; {icon}
children: React.ReactNode; {tabs.map((tab) => (
}) => <div key={tab.title}>
open ? ( <h2>{tab.title}</h2>
<div data-testid="dialog"> {tab.children}
{children} </div>
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}> ))}
Close </div>
</button> )),
</div>
) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-content">{children}</div>
),
DialogHeader: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-header">{children}</div>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<p data-testid="dialog-description">{children}</p>
),
DialogBody: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-body">{children}</div>
),
})); }));
vi.mock("./ActionActivityTab", () => ({ vi.mock("./ActionActivityTab", () => ({
@@ -61,22 +44,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
}, },
})); }));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => {
const translations = {
"common.activity": "Activity",
"common.settings": "Settings",
"common.no_code": "No Code",
"common.action": "Action",
"common.code": "Code",
};
return translations[key] || key;
},
}),
}));
const mockEnvironmentId = "test-env-id"; const mockEnvironmentId = "test-env-id";
const mockSetOpen = vi.fn(); const mockSetOpen = vi.fn();
@@ -122,68 +89,58 @@ describe("ActionDetailModal", () => {
vi.clearAllMocks(); // Clear mocks after each test vi.clearAllMocks(); // Clear mocks after each test
}); });
test("renders correctly when open", () => { test("renders ModalWithTabs with correct props", () => {
render(<ActionDetailModal {...defaultProps} />); render(<ActionDetailModal {...defaultProps} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument(); const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
expect(screen.getByText("Activity")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
// Only the first tab (Activity) should be active initially
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
test("does not render when open is false", () => { expect(mockedModalWithTabs).toHaveBeenCalled();
render(<ActionDetailModal {...defaultProps} open={false} />); const props = mockedModalWithTabs.mock.calls[0][0];
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
});
test("switches tabs correctly", async () => { // Check basic props
const user = userEvent.setup(); expect(props.open).toBe(true);
render(<ActionDetailModal {...defaultProps} />); expect(props.setOpen).toBe(mockSetOpen);
expect(props.label).toBe(mockActionClass.name);
expect(props.description).toBe(mockActionClass.description);
// Initially shows activity tab (first tab is active) // Check icon data-testid based on the mock for the default 'code' type
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); expect(props.icon).toBeDefined();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument(); if (!props.icon) {
throw new Error("Icon prop is not defined");
}
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
// Click settings tab // Check tabs structure
const settingsTab = screen.getByText("Settings"); expect(props.tabs).toHaveLength(2);
await user.click(settingsTab); expect(props.tabs[0].title).toBe("common.activity");
expect(props.tabs[1].title).toBe("common.settings");
// Now shows settings tab content // Check if the correct mocked components are used as children
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument(); // Access the mocked functions directly
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); const mockedActionActivityTab = vi.mocked(ActionActivityTab);
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
// Click activity tab again if (!props.tabs[0].children || !props.tabs[1].children) {
const activityTab = screen.getByText("Activity"); throw new Error("Tabs children are not defined");
await user.click(activityTab); }
// Back to activity tab content expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument(); expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
});
test("resets to first tab when modal is reopened", async () => { // Check props passed to child components
const user = userEvent.setup(); const activityTabProps = (props.tabs[0].children as any).props;
const { rerender } = render(<ActionDetailModal {...defaultProps} />); expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
expect(activityTabProps.isReadOnly).toBe(false);
expect(activityTabProps.environment).toBe(mockEnvironment);
expect(activityTabProps.actionClass).toBe(mockActionClass);
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
// Switch to settings tab const settingsTabProps = (props.tabs[1].children as any).props;
const settingsTab = screen.getByText("Settings"); expect(settingsTabProps.actionClass).toBe(mockActionClass);
await user.click(settingsTab); expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument(); expect(settingsTabProps.setOpen).toBe(mockSetOpen);
expect(settingsTabProps.isReadOnly).toBe(false);
// Close modal
rerender(<ActionDetailModal {...defaultProps} open={false} />);
// Reopen modal
rerender(<ActionDetailModal {...defaultProps} open={true} />);
// Should be back to activity tab (first tab)
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
}); });
test("renders correct icon based on action type", () => { test("renders correct icon based on action type", () => {
@@ -191,68 +148,33 @@ describe("ActionDetailModal", () => {
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass; const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />); render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
expect(screen.getByTestId("nocode-icon")).toBeInTheDocument(); const mockedModalWithTabs = vi.mocked(ModalWithTabs);
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument(); const props = mockedModalWithTabs.mock.calls[0][0];
});
test("handles action without description", () => { // Expect the 'nocode-icon' based on the updated mock and action type
const actionWithoutDescription = { ...mockActionClass, description: "" }; expect(props.icon).toBeDefined();
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action"); if (!props.icon) {
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action"); throw new Error("Icon prop is not defined");
}); }
test("passes correct props to ActionActivityTab", () => { expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
render(<ActionDetailModal {...defaultProps} />);
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
expect(mockedActionActivityTab).toHaveBeenCalledWith(
{
otherEnvActionClasses: mockOtherEnvActionClasses,
otherEnvironment: mockOtherEnvironment,
isReadOnly: false,
environment: mockEnvironment,
actionClass: mockActionClass,
environmentId: mockEnvironmentId,
},
undefined
);
});
test("passes correct props to ActionSettingsTab when tab is active", async () => {
const user = userEvent.setup();
render(<ActionDetailModal {...defaultProps} />);
// ActionSettingsTab should not be called initially since first tab is active
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
// Click the settings tab to activate ActionSettingsTab
const settingsTab = screen.getByText("Settings");
await user.click(settingsTab);
// Now ActionSettingsTab should be called with correct props
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
{
actionClass: mockActionClass,
actionClasses: mockActionClasses,
setOpen: mockSetOpen,
isReadOnly: false,
},
undefined
);
}); });
test("passes isReadOnly prop correctly", () => { test("passes isReadOnly prop correctly", () => {
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />); render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
// Access the mocked component directly
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
const props = mockedModalWithTabs.mock.calls[0][0];
const mockedActionActivityTab = vi.mocked(ActionActivityTab); if (!props.tabs[0].children || !props.tabs[1].children) {
expect(mockedActionActivityTab).toHaveBeenCalledWith( throw new Error("Tabs children are not defined");
expect.objectContaining({ }
isReadOnly: true,
}), const activityTabProps = (props.tabs[0].children as any).props;
undefined expect(activityTabProps.isReadOnly).toBe(true);
);
const settingsTabProps = (props.tabs[1].children as any).props;
expect(settingsTabProps.isReadOnly).toBe(true);
}); });
}); });
@@ -59,24 +59,16 @@ export const ActionDetailModal = ({
}, },
]; ];
const typeDescription = () => {
if (actionClass.description) return actionClass.description;
else
return (
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
" " +
t("common.action").toLowerCase()
);
};
return ( return (
<ModalWithTabs <>
open={open} <ModalWithTabs
setOpen={setOpen} open={open}
tabs={tabs} setOpen={setOpen}
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]} tabs={tabs}
label={actionClass.name} icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
description={typeDescription()} label={actionClass.name}
/> description={actionClass.description || ""}
/>
</>
); );
}; };
@@ -11,21 +11,22 @@ export const ActionClassDataRow = ({
locale: TUserLocale; locale: TUserLocale;
}) => { }) => {
return ( return (
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"> <div className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
<div className="col-span-4 flex items-start py-3 pl-6 text-sm"> <div className="col-span-4 flex items-center pl-6 text-sm">
<div className="flex w-full items-center gap-4"> <div className="flex items-center">
<div className="mt-1 h-5 w-5 flex-shrink-0 text-slate-500"> <div className="h-5 w-5 flex-shrink-0 text-slate-500">
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]} {ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
</div> </div>
<div className="text-left"> <div className="ml-4 text-left">
<div className="break-words font-medium text-slate-900">{actionClass.name}</div> <div className="font-medium text-slate-900">{actionClass.name}</div>
<div className="break-words text-xs text-slate-400">{actionClass.description}</div> <div className="text-xs text-slate-400">{actionClass.description}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500"> <div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
{timeSince(actionClass.createdAt.toString(), locale)} {timeSince(actionClass.createdAt.toString(), locale)}
</div> </div>
<div className="text-center"></div>
</div> </div>
); );
}; };
@@ -11,21 +11,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
updateActionClassAction: vi.fn(), 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 // Mock utils
vi.mock("@/app/lib/actionClass/actionClass", () => ({ vi.mock("@/app/lib/actionClass/actionClass", () => ({
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"), isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
@@ -39,7 +24,6 @@ vi.mock("@/modules/ui/components/button", () => ({
</button> </button>
), ),
})); }));
vi.mock("@/modules/ui/components/code-action-form", () => ({ vi.mock("@/modules/ui/components/code-action-form", () => ({
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="code-action-form" data-readonly={isReadOnly}> <div data-testid="code-action-form" data-readonly={isReadOnly}>
@@ -47,7 +31,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/delete-dialog", () => ({ vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) => DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
open ? ( open ? (
@@ -60,26 +43,6 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
</div> </div>
) : null, ) : 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", () => ({ vi.mock("@/modules/ui/components/no-code-action-form", () => ({
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => ( NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
<div data-testid="no-code-action-form" data-readonly={isReadOnly}> <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>, 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 mockSetOpen = vi.fn();
const mockActionClasses: TActionClass[] = [ const mockActionClasses: TActionClass[] = [
{ {
@@ -142,7 +88,6 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
describe("ActionSettingsTab", () => { describe("ActionSettingsTab", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockHandleSubmit.mockImplementation((fn) => fn);
}); });
afterEach(() => { afterEach(() => {
@@ -160,9 +105,13 @@ describe("ActionSettingsTab", () => {
/> />
); );
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument(); // Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument(); expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument(); actionClass.name
);
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
actionClass.description
);
expect(screen.getByTestId("code-action-form")).toBeInTheDocument(); expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
expect( expect(
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base") 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.getByTestId("no-code-action-form")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /common.delete/ })).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 () => { test("handles successful deletion", async () => {
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action"); const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
const { deleteActionClassAction } = await import( const { deleteActionClassAction } = await import(
@@ -346,16 +209,17 @@ describe("ActionSettingsTab", () => {
actionClass={actionClass} actionClass={actionClass}
actionClasses={mockActionClasses} actionClasses={mockActionClasses}
setOpen={mockSetOpen} setOpen={mockSetOpen}
isReadOnly={true} isReadOnly={true} // Set to read-only
/> />
); );
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled(); // Use getByPlaceholderText or getByLabelText now that Input isn't mocked
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled(); 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.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.save_changes" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /common.delete/ })).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 () => { test("prevents delete when read-only", async () => {
@@ -364,6 +228,7 @@ describe("ActionSettingsTab", () => {
"@/app/(app)/environments/[environmentId]/actions/actions" "@/app/(app)/environments/[environmentId]/actions/actions"
); );
// Render with isReadOnly=true, but simulate a delete attempt
render( render(
<ActionSettingsTab <ActionSettingsTab
actionClass={actionClass} 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(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
expect(deleteActionClassAction).not.toHaveBeenCalled(); expect(deleteActionClassAction).not.toHaveBeenCalled();
}); });
@@ -391,19 +262,4 @@ describe("ActionSettingsTab", () => {
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code"); expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
expect(docsLink).toHaveAttribute("target", "_blank"); 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();
});
}); });
@@ -4,17 +4,14 @@ import {
deleteActionClassAction, deleteActionClassAction,
updateActionClassAction, updateActionClassAction,
} from "@/app/(app)/environments/[environmentId]/actions/actions"; } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder"; import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
import {
createActionClassZodResolver,
useActionClassKeys,
validatePermissions,
} from "@/modules/survey/editor/lib/action-utils";
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { CodeActionForm } from "@/modules/ui/components/code-action-form"; import { CodeActionForm } from "@/modules/ui/components/code-action-form";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog"; 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 { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { TrashIcon } from "lucide-react"; import { TrashIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
@@ -22,7 +19,8 @@ import { useRouter } from "next/navigation";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; 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 { interface ActionSettingsTabProps {
actionClass: TActionClass; actionClass: TActionClass;
@@ -50,51 +48,63 @@ export const ActionSettingsTab = ({
[actionClass.id, actionClasses] [actionClass.id, actionClasses]
); );
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
const form = useForm<TActionClassInput>({ const form = useForm<TActionClassInput>({
defaultValues: { defaultValues: {
...restActionClass, ...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", mode: "onChange",
}); });
const { handleSubmit, control } = form; 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) => { const onSubmit = async (data: TActionClassInput) => {
try { try {
if (isReadOnly) {
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
}
setIsUpdatingAction(true); 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({ await updateActionClassAction({
actionClassId: actionClass.id, actionClassId: actionClass.id,
updatedAction: updatedAction, updatedAction: updatedData,
}); });
setOpen(false); setOpen(false);
router.refresh(); router.refresh();
@@ -113,7 +123,7 @@ export const ActionSettingsTab = ({
router.refresh(); router.refresh();
toast.success(t("environments.actions.action_deleted_successfully")); toast.success(t("environments.actions.action_deleted_successfully"));
setOpen(false); setOpen(false);
} catch { } catch (error) {
toast.error(t("common.something_went_wrong_please_try_again")); toast.error(t("common.something_went_wrong_please_try_again"));
} finally { } finally {
setIsDeletingAction(false); setIsDeletingAction(false);
@@ -125,23 +135,89 @@ export const ActionSettingsTab = ({
<FormProvider {...form}> <FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto"> <div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
<ActionNameDescriptionFields <div className="grid w-full grid-cols-2 gap-x-4">
control={control} <div className="col-span-1">
isReadOnly={isReadOnly} <FormField
nameInputId="actionNameSettingsInput" control={control}
descriptionInputId="actionDescriptionSettingsInput" 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>
<div className="flex justify-between gap-x-2 border-slate-200 pt-4"> <div className="flex justify-between border-t border-slate-200 py-6">
<div className="flex items-center gap-x-2"> <div>
{!isReadOnly ? ( {!isReadOnly ? (
<Button <Button
type="button" type="button"
variant="destructive" variant="destructive"
onClick={() => setOpenDeleteDialog(true)} onClick={() => setOpenDeleteDialog(true)}
className="mr-3"
id="deleteActionModalTrigger"> id="deleteActionModalTrigger">
<TrashIcon /> <TrashIcon />
{t("common.delete")} {t("common.delete")}
@@ -22,29 +22,14 @@ vi.mock("@/modules/ui/components/button", () => ({
), ),
})); }));
vi.mock("@/modules/ui/components/dialog", () => ({ vi.mock("@/modules/ui/components/modal", () => ({
Dialog: ({ children, open, onOpenChange }: any) => Modal: ({ children, open, setOpen, ...props }: any) =>
open ? ( open ? (
<div data-testid="dialog" role="dialog"> <div data-testid="modal" {...props}>
{children} {children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button> <button onClick={() => setOpen(false)}>Close Modal</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children, className }: any) => (
<h2 data-testid="dialog-title" className={className}>
{children}
</h2>
),
DialogDescription: ({ children }: { children: React.ReactNode }) => (
<div data-testid="dialog-description">{children}</div>
),
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
})); }));
vi.mock("@tolgee/react", () => ({ vi.mock("@tolgee/react", () => ({
@@ -85,21 +70,17 @@ describe("AddActionModal", () => {
); );
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
expect(screen.getByTestId("plus-icon")).toBeInTheDocument(); expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
}); });
test("opens the dialog when the 'Add Action' button is clicked", async () => { test("opens the modal when the 'Add Action' button is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument(); expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument(); expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
expect( expect(
@@ -127,35 +108,35 @@ describe("AddActionModal", () => {
expect(props.setActionClasses).toBeInstanceOf(Function); expect(props.setActionClasses).toBeInstanceOf(Function);
}); });
test("closes the dialog when the close button (simulated) is clicked", async () => { test("closes the modal when the close button (simulated) is clicked", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked Dialog's close button // Simulate closing via the mocked Modal's close button
const closeDialogButton = screen.getByText("Close Dialog"); const closeModalButton = screen.getByText("Close Modal");
await userEvent.click(closeDialogButton); await userEvent.click(closeModalButton);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
}); });
test("closes the dialog when setOpen is called from CreateNewActionTab", async () => { test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
render( render(
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} /> <AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
); );
const addButton = screen.getByRole("button", { name: "common.add_action" }); const addButton = screen.getByRole("button", { name: "common.add_action" });
await userEvent.click(addButton); await userEvent.click(addButton);
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
// Simulate closing via the mocked CreateNewActionTab's button // Simulate closing via the mocked CreateNewActionTab's button
const closeFromTabButton = screen.getByText("Close from Tab"); const closeFromTabButton = screen.getByText("Close from Tab");
await userEvent.click(closeFromTabButton); await userEvent.click(closeFromTabButton);
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument(); expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
}); });
}); });
@@ -2,14 +2,7 @@
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab"; import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import { Modal } from "@/modules/ui/components/modal";
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { MousePointerClickIcon, PlusIcon } from "lucide-react"; import { MousePointerClickIcon, PlusIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -33,26 +26,36 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
{t("common.add_action")} {t("common.add_action")}
<PlusIcon /> <PlusIcon />
</Button> </Button>
<Dialog open={open} onOpenChange={setOpen}> <Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
<DialogContent disableCloseOnOutsideClick> <div className="flex h-full flex-col rounded-lg">
<DialogHeader> <div className="rounded-t-lg bg-slate-100">
<MousePointerClickIcon /> <div className="flex w-full items-center justify-between p-6">
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle> <div className="flex items-center space-x-2">
<DialogDescription> <div className="mr-1.5 h-6 w-6 text-slate-500">
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")} <MousePointerClickIcon className="h-5 w-5" />
</DialogDescription> </div>
</DialogHeader> <div>
<DialogBody> <div className="text-xl font-medium text-slate-700">
<CreateNewActionTab {t("environments.actions.track_new_user_action")}
actionClasses={newActionClasses} </div>
environmentId={environmentId} <div className="text-sm text-slate-500">
isReadOnly={isReadOnly} {t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
setActionClasses={setNewActionClasses} </div>
setOpen={setOpen} </div>
/> </div>
</DialogBody> </div>
</DialogContent> </div>
</Dialog> </div>
<div className="px-6 py-4">
<CreateNewActionTab
actionClasses={newActionClasses}
environmentId={environmentId}
isReadOnly={isReadOnly}
setActionClasses={setNewActionClasses}
setOpen={setOpen}
/>
</div>
</Modal>
</> </>
); );
}; };
@@ -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 { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
@@ -7,20 +5,22 @@ import {
getMonthlyActiveOrganizationPeopleCount, getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TMembership } from "@formbricks/types/memberships"; import { TMembership } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import {
TOrganization,
TOrganizationBilling,
TOrganizationBillingPlanLimits,
} from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project"; import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
@@ -31,12 +31,16 @@ vi.mock("@/lib/environment/service", () => ({
})); }));
vi.mock("@/lib/organization/service", () => ({ vi.mock("@/lib/organization/service", () => ({
getOrganizationByEnvironmentId: vi.fn(), getOrganizationByEnvironmentId: vi.fn(),
getOrganizationsByUserId: vi.fn(),
getMonthlyActiveOrganizationPeopleCount: vi.fn(), getMonthlyActiveOrganizationPeopleCount: vi.fn(),
getMonthlyOrganizationResponseCount: vi.fn(), getMonthlyOrganizationResponseCount: vi.fn(),
})); }));
vi.mock("@/lib/user/service", () => ({ vi.mock("@/lib/user/service", () => ({
getUser: vi.fn(), getUser: vi.fn(),
})); }));
vi.mock("@/lib/project/service", () => ({
getUserProjects: vi.fn(),
}));
vi.mock("@/lib/membership/service", () => ({ vi.mock("@/lib/membership/service", () => ({
getMembershipByUserIdOrganizationId: vi.fn(), getMembershipByUserIdOrganizationId: vi.fn(),
})); }));
@@ -45,33 +49,13 @@ vi.mock("@/lib/membership/utils", () => ({
})); }));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getOrganizationProjectsLimit: vi.fn(), getOrganizationProjectsLimit: vi.fn(),
getAccessControlPermission: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/lib/roles", () => ({ vi.mock("@/modules/ee/teams/lib/roles", () => ({
getProjectPermissionByUserId: vi.fn(), getProjectPermissionByUserId: vi.fn(),
})); }));
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
getTeamsByOrganizationId: vi.fn(),
}));
vi.mock("@/tolgee/server", () => ({ vi.mock("@/tolgee/server", () => ({
getTranslate: async () => (key: string) => key, getTranslate: async () => (key: string) => key,
})); }));
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 mockIsFormbricksCloud = false;
let mockIsDevelopment = false; let mockIsDevelopment = false;
@@ -87,17 +71,15 @@ vi.mock("@/lib/constants", () => ({
// Mock components // Mock components
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => ( MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
<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>
),
})); }));
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({ vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>, TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
})); }));
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", () => ({ vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>, LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
})); }));
@@ -117,20 +99,23 @@ const mockUser = {
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
emailVerified: new Date(), emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
notificationSettings: { alert: {} }, notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser; } as unknown as TUser;
const mockOrganization = { const mockOrganization = {
id: "org-1", id: "org-1",
name: "Test Org", name: "Test Org",
createdAt: new Date(),
updatedAt: new Date(),
billing: { billing: {
plan: "free", stripeCustomerId: null,
limits: {}, limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
}, } as unknown as TOrganizationBilling,
} as unknown as TOrganization; } as unknown as TOrganization;
const mockEnvironment: TEnvironment = { const mockEnvironment: TEnvironment = {
@@ -171,17 +156,6 @@ const mockProjectPermission = {
role: "admin", role: "admin",
} as any; } as any;
const mockOrganizationTeams = [
{
id: "team-1",
name: "Development Team",
},
{
id: "team-2",
name: "Marketing Team",
},
];
const mockSession: Session = { const mockSession: Session = {
user: { user: {
id: "user-1", id: "user-1",
@@ -193,19 +167,15 @@ describe("EnvironmentLayout", () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(getUser).mockResolvedValue(mockUser); vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment); vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([ vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
{ id: mockOrganization.id, name: mockOrganization.name },
]);
vi.mocked(getOrganizationByEnvironmentId).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(getEnvironments).mockResolvedValue([mockEnvironment]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100); vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500); vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any); vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission); vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
mockIsDevelopment = false; mockIsDevelopment = false;
mockIsFormbricksCloud = false; mockIsFormbricksCloud = false;
}); });
@@ -244,6 +214,33 @@ describe("EnvironmentLayout", () => {
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument(); 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 () => { test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
mockIsFormbricksCloud = true; mockIsFormbricksCloud = true;
vi.resetModules(); vi.resetModules();
@@ -291,84 +288,6 @@ describe("EnvironmentLayout", () => {
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument(); 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 () => { test("throws error if user not found", async () => {
vi.mocked(getUser).mockResolvedValue(null); vi.mocked(getUser).mockResolvedValue(null);
vi.resetModules(); vi.resetModules();
@@ -430,7 +349,7 @@ describe("EnvironmentLayout", () => {
}); });
test("throws error if projects, environments or organizations not found", async () => { 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(); vi.resetModules();
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({ await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
getEnterpriseLicense: vi.fn().mockResolvedValue({ getEnterpriseLicense: vi.fn().mockResolvedValue({
@@ -1,7 +1,5 @@
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation"; import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar"; 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 { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getEnvironment, getEnvironments } from "@/lib/environment/service"; import { getEnvironment, getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
@@ -10,14 +8,14 @@ import {
getMonthlyActiveOrganizationPeopleCount, getMonthlyActiveOrganizationPeopleCount,
getMonthlyOrganizationResponseCount, getMonthlyOrganizationResponseCount,
getOrganizationByEnvironmentId, getOrganizationByEnvironmentId,
getOrganizationsByUserId,
} from "@/lib/organization/service"; } from "@/lib/organization/service";
import { getUserProjects } from "@/lib/project/service";
import { getUser } from "@/lib/user/service"; import { getUser } from "@/lib/user/service";
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license"; import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
import { import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
getAccessControlPermission,
getOrganizationProjectsLimit,
} from "@/modules/ee/license-check/lib/utils";
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles"; import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner"; import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner"; import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
import { getTranslate } from "@/tolgee/server"; 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")); throw new Error(t("common.environment_not_found"));
} }
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id); const [projects, environments] = await Promise.all([
if (!currentUserMembership) { getUserProjects(user.id, organization.id),
throw new Error(t("common.membership_not_found"));
}
const membershipRole = currentUserMembership?.role;
const [projects, environments, isAccessControlAllowed] = await Promise.all([
getProjectsByUserId(user.id, currentUserMembership),
getEnvironments(environment.projectId), getEnvironments(environment.projectId),
getAccessControlPermission(organization.billing.plan),
]); ]);
if (!projects || !environments || !organizations) { if (!projects || !environments || !organizations) {
throw new Error(t("environments.projects_environments_organizations_not_found")); 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 { isMember } = getAccessFlags(membershipRole);
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense(); 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); 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 ( return (
<div className="flex h-screen min-h-screen flex-col overflow-hidden"> <div className="flex h-screen min-h-screen flex-col overflow-hidden">
<DevEnvironmentBanner environment={environment} />
{IS_FORMBRICKS_CLOUD && ( {IS_FORMBRICKS_CLOUD && (
<LimitsReachedBanner <LimitsReachedBanner
organization={organization} organization={organization}
@@ -115,36 +101,30 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
isPendingDowngrade={isPendingDowngrade ?? false} isPendingDowngrade={isPendingDowngrade ?? false}
active={active} active={active}
environmentId={environment.id} environmentId={environment.id}
locale={user.locale}
/> />
<div className="flex h-full"> <div className="flex h-full">
<MainNavigation <MainNavigation
environment={environment} environment={environment}
organization={organization} organization={organization}
organizations={organizations}
projects={projects} projects={projects}
organizationProjectsLimit={organizationProjectsLimit}
user={user} user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD} isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isDevelopment={IS_DEVELOPMENT} isDevelopment={IS_DEVELOPMENT}
membershipRole={membershipRole} 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 <TopControlBar
environment={environment}
environments={environments} 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} membershipRole={membershipRole}
projectPermission={projectPermission} projectPermission={projectPermission}
/> />
<div className="flex-1 overflow-y-auto">{children}</div> <div className="mt-14">{children}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -51,6 +51,13 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
CreateOrganizationModal: ({ open }: { open: boolean }) => CreateOrganizationModal: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null, open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
})); }));
vi.mock("@/modules/projects/components/project-switcher", () => ({
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
Project Switcher
</div>
),
}));
vi.mock("@/modules/ui/components/avatars", () => ({ vi.mock("@/modules/ui/components/avatars", () => ({
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>, ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
})); }));
@@ -93,12 +100,13 @@ const mockUser = {
id: "user1", id: "user1",
name: "Test User", name: "Test User",
email: "test@example.com", email: "test@example.com",
imageUrl: "http://example.com/avatar.png",
emailVerified: new Date(), emailVerified: new Date(),
twoFactorEnabled: false, twoFactorEnabled: false,
identityProvider: "email", identityProvider: "email",
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
notificationSettings: { alert: {} }, notificationSettings: { alert: {}, weeklySummary: {} },
role: "project_manager", role: "project_manager",
objective: "other", objective: "other",
} as unknown as TUser; } as unknown as TUser;
@@ -138,7 +146,6 @@ const defaultProps = {
membershipRole: "owner" as const, membershipRole: "owner" as const,
organizationProjectsLimit: 5, organizationProjectsLimit: 5,
isLicenseActive: true, isLicenseActive: true,
isAccessControlAllowed: true,
}; };
describe("MainNavigation", () => { describe("MainNavigation", () => {
@@ -159,11 +166,13 @@ describe("MainNavigation", () => {
test("renders expanded by default and collapses on toggle", async () => { test("renders expanded by default and collapses on toggle", async () => {
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
const projectSwitcher = screen.getByTestId("project-switcher");
// Assuming the toggle button is the only one initially without an accessible name // 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. // A more specific selector like data-testid would be better if available.
const toggleButton = screen.getByRole("button", { name: "" }); const toggleButton = screen.getByRole("button", { name: "" });
// Check initial state (expanded) // Check initial state (expanded)
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument(); expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
// Check localStorage is not set initially after clear() // Check localStorage is not set initially after clear()
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull(); expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
@@ -174,6 +183,7 @@ describe("MainNavigation", () => {
// Check state after first toggle (collapsed) // Check state after first toggle (collapsed)
await waitFor(() => { await waitFor(() => {
// Check that the attribute eventually becomes true // Check that the attribute eventually becomes true
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
// Check that localStorage is updated // Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true"); expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
}); });
@@ -188,6 +198,7 @@ describe("MainNavigation", () => {
// Check state after second toggle (expanded) // Check state after second toggle (expanded)
await waitFor(() => { await waitFor(() => {
// Check that the attribute eventually becomes false // Check that the attribute eventually becomes false
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
// Check that localStorage is updated // Check that localStorage is updated
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false"); expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
}); });
@@ -210,6 +221,7 @@ describe("MainNavigation", () => {
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut }); vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
// Set up localStorage spy on the mocked localStorage // Set up localStorage spy on the mocked localStorage
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
render(<MainNavigation {...defaultProps} />); render(<MainNavigation {...defaultProps} />);
@@ -223,24 +235,71 @@ describe("MainNavigation", () => {
expect(screen.getByText("common.account")).toBeInTheDocument(); 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.documentation")).toBeInTheDocument();
expect(screen.getByText("common.logout")).toBeInTheDocument(); expect(screen.getByText("common.logout")).toBeInTheDocument();
const logoutButton = screen.getByText("common.logout"); const logoutButton = screen.getByText("common.logout");
await userEvent.click(logoutButton); await userEvent.click(logoutButton);
// Verify localStorage.removeItem is called with the correct key
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
expect(mockSignOut).toHaveBeenCalledWith({ expect(mockSignOut).toHaveBeenCalledWith({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: "org1", organizationId: "org1",
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
await waitFor(() => { await waitFor(() => {
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login"); expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
}); });
// Clean up spy
removeItemSpy.mockRestore();
});
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("hides new version banner for members or if no new version", async () => {
@@ -270,25 +329,15 @@ describe("MainNavigation", () => {
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument(); expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
}); });
test("passes isAccessControlAllowed props to ProjectSwitcher", () => { test("shows billing link and hides license link in cloud", async () => {
render(<MainNavigation {...defaultProps} />); 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) // Wait for dropdown items
expect(screen.getByRole("complementary")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument(); expect(screen.getByText("common.billing")).toBeInTheDocument();
}); });
expect(screen.queryByText("common.license")).not.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();
}); });
}); });
@@ -2,17 +2,27 @@
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions"; import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink"; 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 FBLogo from "@/images/formbricks-wordmark.svg";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getAccessFlags } from "@/lib/membership/utils"; import { getAccessFlags } from "@/lib/membership/utils";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out"; 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 { ProfileAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu"; } from "@/modules/ui/components/dropdown-menu";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
@@ -21,14 +31,18 @@ import {
BlocksIcon, BlocksIcon,
ChevronRightIcon, ChevronRightIcon,
Cog, Cog,
CreditCardIcon,
KeyIcon,
LogOutIcon, LogOutIcon,
MessageCircle, MessageCircle,
MousePointerClick, MousePointerClick,
PanelLeftCloseIcon, PanelLeftCloseIcon,
PanelLeftOpenIcon, PanelLeftOpenIcon,
PlusIcon,
RocketIcon, RocketIcon,
UserCircleIcon, UserCircleIcon,
UserIcon, UserIcon,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -37,40 +51,53 @@ import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment"; import { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
import { TOrganization } from "@formbricks/types/organizations"; import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
import { TUser } from "@formbricks/types/user"; import { TUser } from "@formbricks/types/user";
import packageJson from "../../../../../package.json"; import packageJson from "../../../../../package.json";
interface NavigationProps { interface NavigationProps {
environment: TEnvironment; environment: TEnvironment;
organizations: TOrganization[];
user: TUser; user: TUser;
organization: TOrganization; organization: TOrganization;
projects: { id: string; name: string }[]; projects: TProject[];
isMultiOrgEnabled: boolean;
isFormbricksCloud: boolean; isFormbricksCloud: boolean;
isDevelopment: boolean; isDevelopment: boolean;
membershipRole?: TOrganizationRole; membershipRole?: TOrganizationRole;
organizationProjectsLimit: number;
isLicenseActive: boolean;
} }
export const MainNavigation = ({ export const MainNavigation = ({
environment, environment,
organizations,
organization, organization,
user, user,
projects, projects,
isMultiOrgEnabled,
membershipRole, membershipRole,
isFormbricksCloud, isFormbricksCloud,
organizationProjectsLimit,
isLicenseActive,
isDevelopment, isDevelopment,
}: NavigationProps) => { }: NavigationProps) => {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { t } = useTranslate(); const { t } = useTranslate();
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true); const [isCollapsed, setIsCollapsed] = useState(true);
const [isTextVisible, setIsTextVisible] = useState(true); const [isTextVisible, setIsTextVisible] = useState(true);
const [latestVersion, setLatestVersion] = useState(""); const [latestVersion, setLatestVersion] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email }); const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const project = projects.find((project) => project.id === environment.projectId); 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 isOwnerOrManager = isManager || isOwner;
const isPricingDisabled = isMember;
const toggleSidebar = () => { const toggleSidebar = () => {
setIsCollapsed(!isCollapsed); setIsCollapsed(!isCollapsed);
@@ -91,11 +118,40 @@ export const MainNavigation = ({
}, [isCollapsed]); }, [isCollapsed]);
useEffect(() => { useEffect(() => {
// Auto collapse project navbar on org and account settings if (organization && organization.name !== "") {
if (pathname?.includes("/settings")) { setCurrentOrganizationName(organization.name);
setIsCollapsed(true); 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( const mainNavigation = useMemo(
() => [ () => [
@@ -141,14 +197,25 @@ export const MainNavigation = ({
icon: UserCircleIcon, icon: UserCircleIcon,
}, },
{ {
label: t("common.documentation"), label: t("common.organization"),
href: "https://formbricks.com/docs", href: `/environments/${environment.id}/settings/general`,
target: "_blank", icon: UsersIcon,
icon: ArrowUpRightIcon,
}, },
{ {
label: t("common.share_feedback"), label: t("common.billing"),
href: "https://github.com/formbricks/formbricks/issues", 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", target: "_blank",
icon: ArrowUpRightIcon, icon: ArrowUpRightIcon,
}, },
@@ -161,7 +228,7 @@ export const MainNavigation = ({
const latestVersionTag = res.data; const latestVersionTag = res.data;
const currentVersionTag = `v${packageJson.version}`; const currentVersionTag = `v${packageJson.version}`;
if (isNewerVersion(currentVersionTag, latestVersionTag)) { if (currentVersionTag !== latestVersionTag) {
setLatestVersion(latestVersionTag); setLatestVersion(latestVersionTag);
} }
} }
@@ -177,7 +244,8 @@ export const MainNavigation = ({
<aside <aside
className={cn( 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", "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> <div>
{/* Logo and Toggle */} {/* Logo and Toggle */}
@@ -243,6 +311,22 @@ export const MainNavigation = ({
</Link> </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 */} {/* User Switch */}
<div className="flex items-center"> <div className="flex items-center">
<DropdownMenu> <DropdownMenu>
@@ -251,27 +335,29 @@ export const MainNavigation = ({
id="userDropdownTrigger" id="userDropdownTrigger"
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none"> className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
<div <div
tabIndex={0}
className={cn( className={cn(
"flex cursor-pointer flex-row items-center gap-3", "flex cursor-pointer flex-row items-center space-x-3",
isCollapsed ? "justify-center px-2" : "px-4" isCollapsed ? "pl-2" : "pl-4"
)}> )}>
<ProfileAvatar userId={user.id} /> <ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
{!isCollapsed && !isTextVisible && ( {!isCollapsed && !isTextVisible && (
<> <>
<div <div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
<p <p
title={user?.email} title={user?.email}
className={cn( 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>} {user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
</p> </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> </div>
<ChevronRightIcon <ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
/>
</> </>
)} )}
</div> </div>
@@ -285,41 +371,89 @@ export const MainNavigation = ({
align="end"> align="end">
{/* Dropdown Items */} {/* Dropdown Items */}
{dropdownNavigation.map((link) => ( {dropdownNavigation.map(
<Link (link) =>
href={link.href} !link.hidden && (
target={link.target} <Link
className="flex w-full items-center" href={link.href}
key={link.label} target={link.target}
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}> className="flex w-full items-center"
<DropdownMenuItem> key={link.label}>
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} /> <DropdownMenuItem>
{link.label} <link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
</DropdownMenuItem> {link.label}
</Link> </DropdownMenuItem>
))} </Link>
)
)}
{/* Logout */} {/* Logout */}
<DropdownMenuItem <DropdownMenuItem
onClick={async () => { onClick={async () => {
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
const route = await signOutWithAudit({ const route = await signOutWithAudit({
reason: "user_initiated", reason: "user_initiated",
redirectUrl: "/auth/login", redirectUrl: "/auth/login",
organizationId: organization.id, organizationId: organization.id,
redirect: false, redirect: false,
callbackUrl: "/auth/login", callbackUrl: "/auth/login",
clearEnvironmentId: true,
}); });
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
}} }}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}> icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
{t("common.logout")} {t("common.logout")}
</DropdownMenuItem> </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> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
</aside> </aside>
)} )}
<CreateOrganizationModal
open={showCreateOrganizationModal}
setOpen={(val) => setShowCreateOrganizationModal(val)}
/>
</> </>
); );
}; };
@@ -28,7 +28,7 @@ const TestComponent = () => {
return ( return (
<div> <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="filterLength">{selectedFilter.filter.length}</div>
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div> <div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div> <div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
@@ -44,7 +44,7 @@ const TestComponent = () => {
filterType: { filterValue: "value1", filterComboBoxValue: "option1" }, filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
}, },
], ],
responseStatus: "complete", onlyComplete: true,
}) })
}> }>
Update Filter Update Filter
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
</ResponseFilterProvider> </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("filterLength").textContent).toBe("0");
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0"); expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0"); expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
const updateButton = screen.getByText("Update Filter"); const updateButton = screen.getByText("Update Filter");
await userEvent.click(updateButton); 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"); expect(screen.getByTestId("filterLength").textContent).toBe("1");
}); });
@@ -16,11 +16,9 @@ export interface FilterValue {
}; };
} }
export type TResponseStatus = "all" | "complete" | "partial";
export interface SelectedFilterValue { export interface SelectedFilterValue {
filter: FilterValue[]; filter: FilterValue[];
responseStatus: TResponseStatus; onlyComplete: boolean;
} }
interface SelectedFilterOptions { interface SelectedFilterOptions {
@@ -49,7 +47,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
// state holds the filter selected value // state holds the filter selected value
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({ const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
filter: [], filter: [],
responseStatus: "all", onlyComplete: false,
}); });
// state holds all the options of the responses fetched // state holds all the options of the responses fetched
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({ const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
@@ -69,7 +67,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
}); });
setSelectedFilter({ setSelectedFilter({
filter: [], filter: [],
responseStatus: "all", onlyComplete: false,
}); });
}, []); }, []);
@@ -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
);
});
});
@@ -1,110 +1,32 @@
"use client"; import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { getAccessFlags } from "@/lib/membership/utils";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team"; import { 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 { TEnvironment } from "@formbricks/types/environment";
import { TOrganizationRole } from "@formbricks/types/memberships"; import { TOrganizationRole } from "@formbricks/types/memberships";
interface TopControlBarProps { interface SideBarProps {
environment: TEnvironment;
environments: 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; membershipRole?: TOrganizationRole;
projectPermission: TTeamPermission | null; projectPermission: TTeamPermission | null;
} }
export const TopControlBar = ({ export const TopControlBar = ({
environment,
environments, environments,
currentOrganizationId,
organizations,
currentProjectId,
projects,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
membershipRole, membershipRole,
projectPermission, projectPermission,
}: TopControlBarProps) => { }: SideBarProps) => {
const { t } = useTranslate();
const { isMember, isBilling } = getAccessFlags(membershipRole);
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
const isReadOnly = isMember && hasReadAccess;
const { environment } = useEnvironment();
return ( return (
<div <div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6" <div className="shadow-xs z-10">
data-testid="fb__global-top-control-bar"> <div className="flex w-fit items-center space-x-2 py-2">
<div className="flex items-center"> <TopControlButtons
<ProjectAndOrgSwitch environment={environment}
currentEnvironmentId={environment.id} environments={environments}
environments={environments} membershipRole={membershipRole}
currentOrganizationId={currentOrganizationId} projectPermission={projectPermission}
organizations={organizations} />
currentProjectId={currentProjectId} </div>
projects={projects}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isOwnerOrManager={isOwnerOrManager}
isMember={isMember}
isAccessControlAllowed={isAccessControlAllowed}
/>
</div>
<div className="z-50 flex items-center space-x-2">
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link
href="https://github.com/formbricks/formbricks/issues"
target="_blank"
aria-label={t("common.share_feedback")}
rel="noopener noreferrer">
<BugIcon />
</Link>
</Button>
</TooltipRenderer>
<TooltipRenderer tooltipContent={t("common.account")}>
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
<CircleUserIcon />
</Link>
</Button>
</TooltipRenderer>
{isBilling || isReadOnly ? (
<></>
) : (
<TooltipRenderer tooltipContent={t("common.new_survey")}>
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
<Link
href={`/environments/${environment.id}/surveys/templates`}
aria-label={t("common.new_survey")}>
<PlusIcon />
</Link>
</Button>
</TooltipRenderer>
)}
</div> </div>
</div> </div>
); );
@@ -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();
});
});
@@ -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>
);
};
@@ -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
});
});
@@ -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>
);
};
@@ -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();
});
});
});
@@ -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>
);
};
@@ -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();
});
});
});
@@ -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>
);
};
@@ -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();
});
});
});
@@ -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>
);
};
@@ -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");
});
});
@@ -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>
);
};
@@ -92,24 +92,14 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/dialog", () => ({ vi.mock("@/modules/ui/components/modal", () => ({
Dialog: ({ children, open, onOpenChange }: any) => Modal: ({ children, open, setOpen }) =>
open ? ( open ? (
<div data-testid="dialog" role="dialog"> <div data-testid="modal">
{children} {children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button> <button onClick={() => setOpen(false)}>Close Modal</button>
</div> </div>
) : null, ) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("@/modules/ui/components/alert", () => ({ vi.mock("@/modules/ui/components/alert", () => ({
Alert: ({ children }) => <div data-testid="alert">{children}</div>, Alert: ({ children }) => <div data-testid="alert">{children}</div>,
@@ -10,16 +10,8 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert"; import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -27,11 +19,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/modules/ui/components/select"; } from "@/modules/ui/components/select";
import { TFnType, useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Control, Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { TIntegrationItem } from "@formbricks/types/integration"; import { TIntegrationItem } from "@formbricks/types/integration";
import { import {
@@ -76,80 +68,6 @@ const NoBaseFoundError = () => {
); );
}; };
const renderQuestionSelection = ({
t,
selectedSurvey,
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
}: {
t: TFnType;
selectedSurvey: TSurvey;
control: Control<IntegrationModalInputs>;
includeVariables: boolean;
setIncludeVariables: (value: boolean) => void;
includeHiddenFields: boolean;
includeMetadata: boolean;
setIncludeHiddenFields: (value: boolean) => void;
setIncludeMetadata: (value: boolean) => void;
includeCreatedAt: boolean;
setIncludeCreatedAt: (value: boolean) => void;
}) => {
return (
<div className="space-y-4">
<div>
<Label htmlFor="Surveys">{t("common.questions")}</Label>
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
<Controller
key={question.id}
control={control}
name={"questions"}
render={({ field }) => (
<div className="my-1 flex items-center space-x-2">
<label htmlFor={question.id} className="flex cursor-pointer items-center">
<Checkbox
type="button"
id={question.id}
value={question.id}
className="bg-white"
checked={field.value?.includes(question.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
</label>
</div>
)}
/>
))}
</div>
</div>
</div>
<AdditionalIntegrationSettings
includeVariables={includeVariables}
setIncludeVariables={setIncludeVariables}
includeHiddenFields={includeHiddenFields}
includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
setIncludeMetadata={setIncludeMetadata}
includeCreatedAt={includeCreatedAt}
setIncludeCreatedAt={setIncludeCreatedAt}
/>
</div>
);
};
export const AddIntegrationModal = ({ export const AddIntegrationModal = ({
open, open,
setOpenWithStates, setOpenWithStates,
@@ -292,148 +210,182 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpenWithStates}> <Modal open={open} setOpen={handleClose} noPadding>
<DialogContent className="overflow-visible md:overflow-visible"> <div className="rounded-t-lg bg-slate-100">
<DialogHeader> <div className="flex w-full items-center justify-between p-6">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative size-8"> <div className="mr-1.5 h-6 w-6 text-slate-500">
<Image <Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
/>
</div> </div>
<div className="space-y-0.5"> <div>
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle> <div className="text-xl font-medium text-slate-700">
<DialogDescription> {t("environments.integrations.airtable.link_airtable_table")}
</div>
<div className="text-sm text-slate-500">
{t("environments.integrations.airtable.sync_responses_with_airtable")} {t("environments.integrations.airtable.sync_responses_with_airtable")}
</DialogDescription> </div>
</div> </div>
</div> </div>
</DialogHeader> </div>
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}> </div>
<DialogBody className="overflow-visible"> <form onSubmit={handleSubmit(submitHandler)}>
<div className="flex w-full flex-col gap-y-4"> <div className="flex rounded-lg p-6">
{airtableArray.length ? ( <div className="flex w-full flex-col gap-y-4 pt-5">
<BaseSelectDropdown {airtableArray.length ? (
control={control} <BaseSelectDropdown
isLoading={isLoading} control={control}
fetchTable={fetchTable} isLoading={isLoading}
airtableArray={airtableArray} fetchTable={fetchTable}
setValue={setValue} airtableArray={airtableArray}
defaultValue={defaultData?.base} setValue={setValue}
/> defaultValue={defaultData?.base}
) : ( />
<NoBaseFoundError /> ) : (
)} <NoBaseFoundError />
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
name="table"
render={({ field }) => (
<Select
required
disabled={!tables.length}
onValueChange={(val) => {
field.onChange(val);
}}
defaultValue={defaultData?.table}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
{tables.length ? (
<SelectContent>
{tables.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name}
</SelectItem>
))}
</SelectContent>
) : null}
</Select>
)}
/>
</div>
</div>
{surveys.length ? (
<div className="flex w-full flex-col"> <div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label> <Label htmlFor="survey">{t("common.select_survey")}</Label>
<div className="mt-1 flex"> <div className="mt-1 flex">
<Controller <Controller
control={control} control={control}
name="table" name="survey"
render={({ field }) => ( render={({ field }) => (
<Select <Select
required required
disabled={!tables.length}
onValueChange={(val) => { onValueChange={(val) => {
field.onChange(val); field.onChange(val);
setValue("questions", []);
}} }}
defaultValue={defaultData?.table}> defaultValue={defaultData?.survey}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
{tables.length ? ( <SelectContent>
<SelectContent> {surveys.map((item) => (
{tables.map((item) => ( <SelectItem key={item.id} value={item.id}>
<SelectItem key={item.id} value={item.id}> {item.name}
{item.name} </SelectItem>
</SelectItem> ))}
))} </SelectContent>
</SelectContent>
) : null}
</Select> </Select>
)} )}
/> />
</div> </div>
</div> </div>
) : null}
{surveys.length ? ( {!surveys.length ? (
<div className="flex w-full flex-col"> <p className="m-1 text-xs text-slate-500">
<Label htmlFor="survey">{t("common.select_survey")}</Label> {t("environments.integrations.create_survey_warning")}
<div className="mt-1 flex"> </p>
<Controller ) : null}
control={control}
name="survey" {survey && selectedSurvey && (
render={({ field }) => ( <div className="space-y-4">
<Select <div>
required <Label htmlFor="Surveys">{t("common.questions")}</Label>
onValueChange={(val) => { <div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
field.onChange(val); <div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
setValue("questions", []); {replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
}} <Controller
defaultValue={defaultData?.survey}> key={question.id}
<SelectTrigger> control={control}
<SelectValue /> name={"questions"}
</SelectTrigger> render={({ field }) => (
<SelectContent> <div className="my-1 flex items-center space-x-2">
{surveys.map((item) => ( <label htmlFor={question.id} className="flex cursor-pointer items-center">
<SelectItem key={item.id} value={item.id}> <Checkbox
{item.name} type="button"
</SelectItem> id={question.id}
))} value={question.id}
</SelectContent> className="bg-white"
</Select> checked={field.value?.includes(question.id)}
)} onCheckedChange={(checked) => {
/> return checked
? field.onChange([...field.value, question.id])
: field.onChange(field.value?.filter((value) => value !== question.id));
}}
/>
<span className="ml-2">
{getLocalizedValue(question.headline, "default")}
</span>
</label>
</div>
)}
/>
))}
</div>
</div> </div>
</div> </div>
) : ( <AdditionalIntegrationSettings
<p className="m-1 text-xs text-slate-500"> includeVariables={includeVariables}
{t("environments.integrations.create_survey_warning")} setIncludeVariables={setIncludeVariables}
</p> includeHiddenFields={includeHiddenFields}
)} includeMetadata={includeMetadata}
setIncludeHiddenFields={setIncludeHiddenFields}
{survey && setIncludeMetadata={setIncludeMetadata}
selectedSurvey && includeCreatedAt={includeCreatedAt}
renderQuestionSelection({ setIncludeCreatedAt={setIncludeCreatedAt}
t, />
selectedSurvey, </div>
control,
includeVariables,
setIncludeVariables,
includeHiddenFields,
includeMetadata,
setIncludeHiddenFields,
setIncludeMetadata,
includeCreatedAt,
setIncludeCreatedAt,
})}
</div>
</DialogBody>
<DialogFooter>
{isEditMode ? (
<Button
onClick={async () => {
await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)} )}
<Button type="submit">{t("common.save")}</Button> <div className="flex justify-end gap-x-2">
</DialogFooter> {isEditMode ? (
</form> <Button
</DialogContent> onClick={async () => {
</Dialog> await handleDelete(defaultData.index);
}}
type="button"
loading={isLoading}
variant="destructive">
{t("common.delete")}
</Button>
) : (
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
)}
<Button type="submit">{t("common.save")}</Button>
</div>
</div>
</div>
</form>
</Modal>
); );
}; };
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
locale: TUserLocale; locale: TUserLocale;
} }
const tableHeaders = [
"common.survey",
"environments.integrations.airtable.table_name",
"common.questions",
"common.updated_at",
];
export const ManageIntegration = (props: ManageIntegrationProps) => { export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props; const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslate(); const { t } = useTranslate();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
const [isDeleting, setisDeleting] = useState(false); const [isDeleting, setisDeleting] = useState(false);
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false); const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>( const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900"> <div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
{tableHeaders.map((header) => ( {tableHeaders.map((header) => (
<div key={header} className={`col-span-2 hidden text-center sm:block`}> <div key={header} className={`col-span-2 hidden text-center sm:block`}>
{header} {t(header)}
</div> </div>
))} ))}
</div> </div>
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn", SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -37,7 +37,7 @@ const Page = async (props) => {
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); redirect("./");
} }
return ( return (
@@ -88,24 +88,9 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
</div> </div>
), ),
})); }));
vi.mock("@/modules/ui/components/dialog", () => ({ vi.mock("@/modules/ui/components/modal", () => ({
Dialog: ({ children, open, onOpenChange }: any) => Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? ( open ? <div data-testid="modal">{children}</div> : null,
<div data-testid="dialog" role="dialog">
{children}
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
) : null,
DialogContent: ({ children, ...props }: any) => (
<div data-testid="dialog-content" {...props}>
{children}
</div>
),
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -220,6 +205,7 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
{ {
@@ -257,6 +243,7 @@ const surveys: TSurvey[] = [
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: true, fieldIds: [] }, hiddenFields: { enabled: true, fieldIds: [] },
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
]; ];
@@ -317,9 +304,10 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); expect(
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -344,9 +332,10 @@ describe("AddIntegrationModal", () => {
/> />
); );
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet"); expect(
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets."); screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
).toBeInTheDocument();
// Use getByPlaceholderText for the input // Use getByPlaceholderText for the input
expect( expect(
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>") screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
@@ -14,18 +14,10 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings"; import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import { Checkbox } from "@/modules/ui/components/checkbox"; import { Checkbox } from "@/modules/ui/components/checkbox";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Input } from "@/modules/ui/components/input"; import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@@ -210,28 +202,31 @@ export const AddIntegrationModal = ({
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpenWithStates}> <Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
<DialogContent> <div className="flex h-full flex-col rounded-lg">
<DialogHeader> <div className="rounded-t-lg bg-slate-100">
<div className="flex items-center space-x-2"> <div className="flex w-full items-center justify-between p-6">
<div className="relative size-8"> <div className="flex items-center space-x-2">
<Image <div className="mr-1.5 h-6 w-6 text-slate-500">
fill <Image
className="object-contain object-center" className="w-12"
src={GoogleSheetLogo} src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")} alt={t("environments.integrations.google_sheets.google_sheet_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <div>
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle> <div className="text-xl font-medium text-slate-700">
<DialogDescription> {t("environments.integrations.google_sheets.link_google_sheet")}
{t("environments.integrations.google_sheets.google_sheets_integration_description")} </div>
</DialogDescription> <div className="text-sm text-slate-500">
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
</div>
</div>
</div> </div>
</div> </div>
</DialogHeader> </div>
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}> <form onSubmit={handleSubmit(linkSheet)}>
<DialogBody> <div className="flex justify-between rounded-lg p-6">
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -297,37 +292,39 @@ export const AddIntegrationModal = ({
</div> </div>
)} )}
</div> </div>
</DialogBody> </div>
<DialogFooter> <div className="flex justify-end border-t border-slate-200 p-6">
{selectedIntegration ? ( <div className="flex space-x-2">
<Button {selectedIntegration ? (
type="button" <Button
variant="destructive" type="button"
loading={isDeleting} variant="destructive"
onClick={() => { loading={isDeleting}
deleteLink(); onClick={() => {
}}> deleteLink();
{t("common.delete")} }}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button> </Button>
) : ( </div>
<Button </div>
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
}}>
{t("common.cancel")}
</Button>
)}
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form> </form>
</DialogContent> </div>
</Dialog> </Modal>
); );
}; };
@@ -119,6 +119,7 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null, displayPercentage: null,
languages: [], languages: [],
pin: null, pin: null,
resultShareKey: null,
segment: null, segment: null,
singleUse: null, singleUse: null,
styling: null, styling: null,
@@ -35,7 +35,7 @@ const Page = async (props) => {
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); redirect("./");
} }
return ( return (
@@ -74,41 +74,13 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
vi.mock("@/modules/ui/components/label", () => ({ vi.mock("@/modules/ui/components/label", () => ({
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>, Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
})); }));
vi.mock("@/modules/ui/components/dialog", () => ({ vi.mock("@/modules/ui/components/modal", () => ({
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) => Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div data-testid="dialog">{children}</div> : null, open ? <div data-testid="modal">{children}</div> : null,
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-content" className={className}>
{children}
</div>
),
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-header" className={className}>
{children}
</div>
),
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<p data-testid="dialog-description" className={className}>
{children}
</p>
),
DialogTitle: ({ children }: { children: React.ReactNode }) => (
<h2 data-testid="dialog-title">{children}</h2>
),
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-body" className={className}>
{children}
</div>
),
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
<div data-testid="dialog-footer" className={className}>
{children}
</div>
),
})); }));
vi.mock("lucide-react", () => ({ vi.mock("lucide-react", () => ({
PlusIcon: () => <span data-testid="plus-icon">+</span>, PlusIcon: () => <span data-testid="plus-icon">+</span>,
TrashIcon: () => <span data-testid="trash-icon">🗑</span>, XIcon: () => <span data-testid="x-icon">x</span>,
})); }));
vi.mock("next/image", () => ({ vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -236,6 +208,7 @@ const surveys: TSurvey[] = [
languages: [], languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
{ {
@@ -271,6 +244,7 @@ const surveys: TSurvey[] = [
languages: [], languages: [],
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"], welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
pin: null, pin: null,
resultShareKey: null,
displayLimit: null, displayLimit: null,
} as unknown as TSurvey, } as unknown as TSurvey,
]; ];
@@ -360,7 +334,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument(); expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument(); expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
@@ -385,7 +359,7 @@ describe("AddIntegrationModal (Notion)", () => {
/> />
); );
expect(screen.getByTestId("dialog")).toBeInTheDocument(); expect(screen.getByTestId("modal")).toBeInTheDocument();
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id); expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id); expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument(); expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
@@ -407,7 +381,7 @@ describe("AddIntegrationModal (Notion)", () => {
expect(columnDropdowns[1]).toHaveValue("p2"); expect(columnDropdowns[1]).toHaveValue("p2");
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0); expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
}); });
expect(screen.getByText("Delete")).toBeInTheDocument(); expect(screen.getByText("Delete")).toBeInTheDocument();
@@ -471,8 +445,8 @@ describe("AddIntegrationModal (Notion)", () => {
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
await userEvent.click(trashButton); await userEvent.click(xButton);
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1); expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
}); });
@@ -12,19 +12,11 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { replaceHeadlineRecall } from "@/lib/utils/recall"; import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { getQuestionTypes } from "@/modules/survey/lib/questions"; import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { Button } from "@/modules/ui/components/button"; import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector"; import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label"; import { Label } from "@/modules/ui/components/label";
import { Modal } from "@/modules/ui/components/modal";
import { useTranslate } from "@tolgee/react"; import { useTranslate } from "@tolgee/react";
import { PlusIcon, TrashIcon } from "lucide-react"; import { PlusIcon, XIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -344,9 +336,9 @@ export const AddIntegrationModal = ({
col={mapping[idx].column} col={mapping[idx].column}
ques={mapping[idx].question} ques={mapping[idx].question}
/> />
<div className="flex w-full items-center space-x-2"> <div className="flex w-full items-center">
<div className="flex w-full items-center"> <div className="flex w-full items-center">
<div className="max-w-full flex-1"> <div className="w-[340px] max-w-full">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")} placeholder={t("environments.integrations.notion.select_a_survey_question")}
items={filteredQuestionItems} items={filteredQuestionItems}
@@ -392,7 +384,7 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
<div className="h-px w-4 border-t border-t-slate-300" /> <div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1"> <div className="w-[340px] max-w-full">
<DropdownSelector <DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")} placeholder={t("environments.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()} items={getFilteredDbItems()}
@@ -438,45 +430,53 @@ export const AddIntegrationModal = ({
/> />
</div> </div>
</div> </div>
<div className="flex space-x-2"> <button
{mapping.length > 1 && ( type="button"
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}> className={`rounded-md p-1 hover:bg-slate-300 ${
<TrashIcon /> idx === mapping.length - 1 ? "visible" : "invisible"
</Button> }`}
)} onClick={addRow}>
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}> <PlusIcon className="h-5 w-5 font-bold text-slate-500" />
<PlusIcon /> </button>
</Button> <button
</div> type="button"
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
mapping.length > 1 ? "visible" : "invisible"
}`}
onClick={deleteRow}>
<XIcon className="h-5 w-5 text-red-500" />
</button>
</div> </div>
</div> </div>
); );
}; };
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
<DialogContent> <div className="flex h-full flex-col rounded-lg">
<DialogHeader> <div className="rounded-t-lg bg-slate-100">
<div className="mb-4 flex items-start space-x-2"> <div className="flex w-full items-center justify-between p-6">
<div className="relative size-8"> <div className="flex items-center space-x-2">
<Image <div className="mr-1.5 h-6 w-6 text-slate-500">
fill <Image
className="object-contain object-center" className="w-12"
src={NotionLogo} src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")} alt={t("environments.integrations.notion.notion_logo")}
/> />
</div> </div>
<div className="space-y-0.5"> <div>
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle> <div className="text-xl font-medium text-slate-700">
<DialogDescription> {t("environments.integrations.notion.link_notion_database")}
{t("environments.integrations.notion.notion_integration_description")} </div>
</DialogDescription> <div className="text-sm text-slate-500">
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
</div>
</div>
</div> </div>
</div> </div>
</DialogHeader> </div>
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4"> <div className="flex justify-between rounded-lg p-6">
<DialogBody>
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div> <div>
<div className="mb-4"> <div className="mb-4">
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
<Label> <Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")} {t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label> </Label>
<div className="mt-1 space-y-2 overflow-y-auto"> <div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
{mapping.map((_, idx) => ( {mapping.map((_, idx) => (
<MappingRow idx={idx} key={idx} /> <MappingRow idx={idx} key={idx} />
))} ))}
@@ -530,40 +530,43 @@ export const AddIntegrationModal = ({
)} )}
</div> </div>
</div> </div>
</DialogBody> </div>
<div className="flex justify-end border-t border-slate-200 p-6">
<DialogFooter> <div className="flex space-x-2">
{selectedIntegration ? ( {selectedIntegration ? (
<Button
type="button"
variant="destructive"
loading={isDeleting}
onClick={() => {
deleteLink();
}}>
{t("common.delete")}
</Button>
) : (
<Button
type="button"
variant="ghost"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button <Button
type="button" type="submit"
variant="destructive" loading={isLinkingDatabase}
loading={isDeleting} disabled={mapping.filter((m) => m.error).length > 0}>
onClick={() => { {selectedIntegration
deleteLink(); ? t("common.update")
}}> : t("environments.integrations.notion.link_database")}
{t("common.delete")}
</Button> </Button>
) : ( </div>
<Button </div>
type="button"
variant="secondary"
onClick={() => {
setOpen(false);
resetForm();
setMapping([]);
}}>
{t("common.cancel")}
</Button>
)}
<Button
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form> </form>
</DialogContent> </div>
</Dialog> </Modal>
); );
}; };
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret", GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url", GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000, SESSION_MAX_AGE: 1000,
REDIS_URL: undefined, REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true, AUDIT_LOG_ENABLED: true,
})); }));
@@ -128,6 +128,7 @@ const mockSurveys: TSurvey[] = [
displayPercentage: null, displayPercentage: null,
languages: [], languages: [],
pin: null, pin: null,
resultShareKey: null,
segment: null, segment: null,
singleUse: null, singleUse: null,
styling: null, styling: null,
@@ -191,7 +192,9 @@ describe("NotionIntegrationPage", () => {
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url"); expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString()); expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
expect(screen.getByTestId("locale")).toHaveTextContent("en-US"); 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(redirect)).not.toHaveBeenCalled();
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id); expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
}); });
@@ -42,12 +42,12 @@ const Page = async (props) => {
const locale = await findMatchingLocale(); const locale = await findMatchingLocale();
if (isReadOnly) { if (isReadOnly) {
return redirect("./"); redirect("./");
} }
return ( return (
<PageContentWrapper> <PageContentWrapper>
<GoBackButton url={"./"} /> <GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} /> <PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
<NotionWrapper <NotionWrapper
enabled={enabled} enabled={enabled}

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