mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-02 18:48:39 -06:00
Compare commits
287 Commits
saml-sso-e
...
fix/upload
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
930db6284b | ||
|
|
379aeba71a | ||
|
|
717adddeae | ||
|
|
41798266a0 | ||
|
|
a93fa8ec76 | ||
|
|
47c3df0466 | ||
|
|
935e24bd43 | ||
|
|
3879d86f63 | ||
|
|
839144d338 | ||
|
|
96031822a6 | ||
|
|
21c8b5d6e4 | ||
|
|
22d4952a40 | ||
|
|
933723f1fe | ||
|
|
dd394f1d2c | ||
|
|
0188aad97b | ||
|
|
d46644fe0d | ||
|
|
c259a61f0e | ||
|
|
feee22b5c3 | ||
|
|
a5433f6748 | ||
|
|
557f14bab8 | ||
|
|
fdba260301 | ||
|
|
764b8ec260 | ||
|
|
ac5d1e651e | ||
|
|
62ffcc8e68 | ||
|
|
326872a86b | ||
|
|
892b55662e | ||
|
|
23143c8664 | ||
|
|
4c71caf0da | ||
|
|
173821f846 | ||
|
|
f139830020 | ||
|
|
70979a3b5b | ||
|
|
061fa036be | ||
|
|
b83c0a4a5d | ||
|
|
1bc0563965 | ||
|
|
3a4e2a9f85 | ||
|
|
bd48139a4f | ||
|
|
89fe82a0d6 | ||
|
|
65dc1fa771 | ||
|
|
438990bffc | ||
|
|
7f7bc989c6 | ||
|
|
baa2b31bc9 | ||
|
|
77aecf3aad | ||
|
|
7c1110239b | ||
|
|
eeb337521b | ||
|
|
182f674879 | ||
|
|
73c0da4b75 | ||
|
|
f475b2e6d5 | ||
|
|
e5e8941016 | ||
|
|
c39c9998f0 | ||
|
|
a8c8e6f83f | ||
|
|
8a5e9f38d7 | ||
|
|
a0740d20ea | ||
|
|
71f378a494 | ||
|
|
4bececeb56 | ||
|
|
71c96f48d7 | ||
|
|
05d88a3069 | ||
|
|
b6a63edc88 | ||
|
|
a3764f0316 | ||
|
|
ec52bdf3fe | ||
|
|
2e9ad3ce07 | ||
|
|
654bd232d6 | ||
|
|
01984cf8ca | ||
|
|
3eb18bb120 | ||
|
|
59859d0e4f | ||
|
|
c60c8cb7bd | ||
|
|
9fa7aef253 | ||
|
|
a23594428a | ||
|
|
56e7106d6e | ||
|
|
318f891540 | ||
|
|
a59881f9ae | ||
|
|
7ab4a45ad6 | ||
|
|
2990e3805f | ||
|
|
29132ab029 | ||
|
|
f860d8d25d | ||
|
|
3501990a79 | ||
|
|
41d60c8a02 | ||
|
|
a6269f0fd3 | ||
|
|
9c0d0a16a7 | ||
|
|
c6241f7e7f | ||
|
|
92f1c2b75a | ||
|
|
4d53291c8a | ||
|
|
14b7a69cea | ||
|
|
a9015b008d | ||
|
|
d19d624c0c | ||
|
|
3edaab6c2b | ||
|
|
4786ab61e7 | ||
|
|
819380d21c | ||
|
|
fd3fedb6ed | ||
|
|
88b1e63771 | ||
|
|
3132fe74f1 | ||
|
|
a27a2a67c8 | ||
|
|
4a7ace5a0a | ||
|
|
43628caa3b | ||
|
|
9d84bc0c8d | ||
|
|
babc020085 | ||
|
|
95ee83ef31 | ||
|
|
d994af2dfd | ||
|
|
4b5b5bf59f | ||
|
|
62166dc4b1 | ||
|
|
ec6d88bf11 | ||
|
|
c0240d60a1 | ||
|
|
cd2884d83e | ||
|
|
f7aea2e706 | ||
|
|
e80fc2ee61 | ||
|
|
9b489b0682 | ||
|
|
2ee0efa1c2 | ||
|
|
9ffd67262c | ||
|
|
68dc63ce0b | ||
|
|
f239ee9697 | ||
|
|
282b3e070c | ||
|
|
b5f0bd8f9a | ||
|
|
3784bd6b5e | ||
|
|
41d27c2093 | ||
|
|
7400ce2e67 | ||
|
|
355782f404 | ||
|
|
de70e97940 | ||
|
|
287c45f996 | ||
|
|
3b07a6d013 | ||
|
|
0cc2606ec6 | ||
|
|
0fada94b80 | ||
|
|
a59ede20c7 | ||
|
|
84294f9df2 | ||
|
|
855e7c78ce | ||
|
|
6c506d90c7 | ||
|
|
53f6e02ca1 | ||
|
|
14de2eab42 | ||
|
|
ad1f80331a | ||
|
|
3527ac337b | ||
|
|
23c2d3dce9 | ||
|
|
da652bd860 | ||
|
|
6f88dde1a0 | ||
|
|
3b90223101 | ||
|
|
e29a67b1f6 | ||
|
|
78f5de2f35 | ||
|
|
b1a35d4a69 | ||
|
|
2166c44470 | ||
|
|
080cf741e9 | ||
|
|
8881691509 | ||
|
|
3045f4437f | ||
|
|
91ace0e821 | ||
|
|
6ef281647a | ||
|
|
0aaaaa54ee | ||
|
|
b1f78e7bf2 | ||
|
|
7086ce2ca3 | ||
|
|
8f8b549b1d | ||
|
|
28514487e0 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 | ||
|
|
f043314537 | ||
|
|
2ce842dd8d | ||
|
|
43b43839c5 | ||
|
|
8b6e3fec37 | ||
|
|
31bcf98779 | ||
|
|
b35cabcbcc | ||
|
|
4f435f1a1f | ||
|
|
99c1e434df | ||
|
|
b13699801b | ||
|
|
ceb2e85d96 | ||
|
|
c5f8b5ec32 | ||
|
|
bdbd57c2fc | ||
|
|
d44aa17814 | ||
|
|
23d38b4c5b | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 | ||
|
|
18ba5bbd8a | ||
|
|
572b613034 | ||
|
|
a9c7140ba6 | ||
|
|
7fa95cd74a | ||
|
|
8c7f36d496 | ||
|
|
42dcbd3e7e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea | ||
|
|
75d170bce5 | ||
|
|
16caae6dd6 | ||
|
|
a490600479 | ||
|
|
be28641722 | ||
|
|
4fdea3221b | ||
|
|
fef30c54b2 | ||
|
|
75362eac7a | ||
|
|
6e3b224944 | ||
|
|
ef1be219b4 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 | ||
|
|
2c34f43c83 | ||
|
|
979fd71a11 | ||
|
|
1be23eebbb | ||
|
|
d10cff917d | ||
|
|
da72101320 | ||
|
|
5f02ad49c1 | ||
|
|
6644bba6ea | ||
|
|
0b7734f725 | ||
|
|
1536bf6907 | ||
|
|
e81190214f | ||
|
|
48c8906a89 | ||
|
|
717b30115b | ||
|
|
1f3962d2d5 | ||
|
|
619f6e408f | ||
|
|
4a8719abaa | ||
|
|
7b59eb3b26 | ||
|
|
8ac280268d | ||
|
|
34e8f4931d | ||
|
|
ac46850a24 | ||
|
|
6328be220a | ||
|
|
882ad99ed7 | ||
|
|
ce47b4c2d8 | ||
|
|
ce8f9de8ec | ||
|
|
ed3c2d2b58 | ||
|
|
9ae226329b | ||
|
|
12c3899b85 | ||
|
|
ccb1353eb5 | ||
|
|
22eb0b79ee | ||
|
|
5eb7a496da | ||
|
|
7ea55e199f | ||
|
|
83eb472acd | ||
|
|
d9fe6ee4f4 | ||
|
|
51b58be079 | ||
|
|
397643330a | ||
|
|
e5fa4328e1 | ||
|
|
4b777f1907 | ||
|
|
c3547ccb36 | ||
|
|
a0f334b300 | ||
|
|
a9f635b768 | ||
|
|
d385b4a0d6 | ||
|
|
5e825413d2 | ||
|
|
8c3e816ccd | ||
|
|
6ddc91ee85 | ||
|
|
14023ca8a9 | ||
|
|
385e8a4262 | ||
|
|
e358104f7c | ||
|
|
c8e9194ab6 | ||
|
|
bebe29815d | ||
|
|
7f40502c94 | ||
|
|
5fb5215680 | ||
|
|
19b80ff042 | ||
|
|
2dfdba2acf | ||
|
|
f7842789de | ||
|
|
59bdd5f065 | ||
|
|
8da1bc71a6 | ||
|
|
0e0259691c | ||
|
|
ac7831fa3d | ||
|
|
db32cb392f | ||
|
|
e5cb01bd88 | ||
|
|
cbef4c2a69 | ||
|
|
86948b70de | ||
|
|
dfe955ca7c | ||
|
|
eb4b2dde05 | ||
|
|
f2dae67813 | ||
|
|
3ffc9bd290 | ||
|
|
a9946737df | ||
|
|
ece3d508a2 | ||
|
|
0d1d227e6a |
@@ -16,9 +16,10 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
||||
## Key Files
|
||||
|
||||
### Core Cache Infrastructure
|
||||
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
|
||||
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
|
||||
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
|
||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
||||
|
||||
### Environment State Caching (Critical Endpoint)
|
||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
||||
@@ -26,7 +27,7 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
||||
|
||||
## Enterprise-Grade Cache Key Patterns
|
||||
|
||||
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
|
||||
**Always use** the `createCacheKey` utilities from the cache package:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct patterns
|
||||
@@ -50,14 +51,14 @@ export const getEnterpriseLicense = reactCache(async () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Use `withCache()` for Simple Database Queries
|
||||
### Use `cache.withCache()` for Simple Database Queries
|
||||
```typescript
|
||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
||||
export const getActionClasses = (environmentId: string) => {
|
||||
return withCache(() => fetchActionClassesFromDB(environmentId), {
|
||||
key: createCacheKey.environment.actionClasses(environmentId),
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
})();
|
||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
||||
createCacheKey.environment.actionClasses(environmentId),
|
||||
60 * 30 * 1000 // 30 minutes in milliseconds
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
110
.cursor/rules/database.mdc
Normal file
110
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,110 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
## Database Overview
|
||||
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
└── Environment (production/development)
|
||||
├── Survey
|
||||
├── Contact
|
||||
├── ActionClass
|
||||
└── Integration
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
|
||||
Some models use soft deletion patterns:
|
||||
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
|
||||
Configured cascade relationships:
|
||||
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
28
.cursor/rules/documentations.mdc
Normal file
28
.cursor/rules/documentations.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
||||
- The page should never start with H1 headline, because it's already part of the template.
|
||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
@@ -18,7 +18,6 @@ apps/web/
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
│ └── share/ # Public sharing routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
- `[sharingKey]` - Public sharing routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
|
||||
232
.cursor/rules/github-actions-security.mdc
Normal file
232
.cursor/rules/github-actions-security.mdc
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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)
|
||||
216
.cursor/rules/storybook-component-migration.mdc
Normal file
216
.cursor/rules/storybook-component-migration.mdc
Normal file
@@ -0,0 +1,216 @@
|
||||
---
|
||||
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)
|
||||
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
177
.cursor/rules/storybook-create-new-story.mdc
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
description: Create a story in Storybook for a given component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Storybook Stories
|
||||
|
||||
## When generating Storybook stories for Formbricks components:
|
||||
|
||||
### 1. **File Structure**
|
||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
||||
- Import component from `"./index"`
|
||||
|
||||
### 2. **Story Structure Template**
|
||||
```tsx
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ComponentName } from "./index";
|
||||
|
||||
// For complex components with configurable options
|
||||
// consider this as an example the options need to reflect the props types
|
||||
interface StoryOptions {
|
||||
showIcon: boolean;
|
||||
numberOfElements: number;
|
||||
customLabels: string[];
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/ComponentName",
|
||||
component: ComponentName,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: "The **ComponentName** component provides [description].",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
||||
```
|
||||
|
||||
### 3. **ArgTypes Organization**
|
||||
Organize ALL argTypes into exactly three categories:
|
||||
- **Behavior**: disabled, variant, onChange, etc.
|
||||
- **Appearance**: size, color, layout, styling, etc.
|
||||
- **Content**: text, icons, numberOfElements, etc.
|
||||
|
||||
Format:
|
||||
```tsx
|
||||
argTypes: {
|
||||
propName: {
|
||||
control: "select" | "boolean" | "text" | "number",
|
||||
options: ["option1", "option2"], // for select
|
||||
description: "Clear description",
|
||||
table: {
|
||||
category: "Behavior" | "Appearance" | "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Required Stories**
|
||||
Every component must include:
|
||||
- `Default`: Most common use case
|
||||
- `Disabled`: If component supports disabled state
|
||||
- `WithIcon`: If component supports icons
|
||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
||||
|
||||
### 5. **Story Format**
|
||||
```tsx
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Props with realistic values
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeCase: Story = {
|
||||
args: { /* ... */ },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this when [specific scenario].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. **Dynamic Content Pattern**
|
||||
For components with dynamic content, create render function:
|
||||
```tsx
|
||||
const renderComponent = (args: StoryProps) => {
|
||||
const { numberOfElements, showIcon, customLabels } = args;
|
||||
|
||||
// Generate dynamic content
|
||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
||||
id: `element-${i}`,
|
||||
label: customLabels[i] || `Element ${i + 1}`,
|
||||
icon: showIcon ? <IconComponent /> : undefined,
|
||||
}));
|
||||
|
||||
return <ComponentName {...args} elements={elements} />;
|
||||
};
|
||||
|
||||
export const Dynamic: Story = {
|
||||
render: renderComponent,
|
||||
args: {
|
||||
numberOfElements: 3,
|
||||
showIcon: true,
|
||||
customLabels: ["First", "Second", "Third"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 7. **State Management**
|
||||
For interactive components:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
const ComponentWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.defaultValue);
|
||||
|
||||
return (
|
||||
<ComponentName
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue);
|
||||
args.onChange?.(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: ComponentWithState,
|
||||
args: { defaultValue: "initial" },
|
||||
};
|
||||
```
|
||||
|
||||
### 8. **Quality Requirements**
|
||||
- Include component description in parameters.docs
|
||||
- Add story documentation for non-obvious use cases
|
||||
- Test edge cases (overflow, empty states, many elements)
|
||||
- Ensure no TypeScript errors
|
||||
- Use realistic prop values
|
||||
- Include at least 3-5 story variants
|
||||
- Example values need to be in the context of survey application
|
||||
|
||||
### 9. **Naming Conventions**
|
||||
- **Story titles**: "UI/ComponentName"
|
||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
||||
- **Props**: camelCase matching component props
|
||||
|
||||
### 10. **Special Cases**
|
||||
- **Generic components**: Remove `component` from meta if type conflicts
|
||||
- **Form components**: Include Invalid, WithValue stories
|
||||
- **Navigation**: Include ManyItems stories
|
||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
||||
|
||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
||||
@@ -90,7 +90,7 @@ When testing hooks that use React Context:
|
||||
vi.mocked(useResponseFilter).mockReturnValue({
|
||||
selectedFilter: {
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
},
|
||||
setSelectedFilter: vi.fn(),
|
||||
selectedOptions: {
|
||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Test sharing mode
|
||||
vi.mocked(useParams).mockReturnValue({
|
||||
surveyId: "123",
|
||||
sharingKey: "share-123"
|
||||
});
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -3,4 +3,5 @@ description: Whenever the user asks to write or update a test file for .tsx or .
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md).
|
||||
After writing the tests, run them and check if there's any issue with the tests and if all of them are passing. Fix the issues and rerun the tests until all pass.
|
||||
22
.env.example
22
.env.example
@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
|
||||
|
||||
# Uncomment the variables you would like to use and customize the values.
|
||||
|
||||
# Custom local storage path for file uploads
|
||||
#UPLOADS_DIR=
|
||||
|
||||
##############
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
@@ -80,8 +77,8 @@ S3_ENDPOINT_URL=
|
||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||
S3_FORCE_PATH_STYLE=0
|
||||
|
||||
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||
# SURVEY_URL=https://survey.example.com
|
||||
# Set this URL to add a public domain for all your client facing routes(default is WEBAPP_URL)
|
||||
# PUBLIC_URL=https://survey.example.com
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
@@ -99,8 +96,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
|
||||
# DOCKER_CRON_ENABLED=1
|
||||
|
||||
##########
|
||||
# Other #
|
||||
@@ -189,15 +184,11 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
# REDIS_URL=redis://localhost:6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -210,9 +201,16 @@ UNKEY_ROOT_KEY=
|
||||
# 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.
|
||||
# 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)
|
||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
# Audit logs options. Default 0.
|
||||
# AUDIT_LOG_ENABLED=0
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
13
.eslintrc.cjs
Normal file
13
.eslintrc.cjs
Normal file
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
ignorePatterns: ["node_modules/", "dist/", "coverage/"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["packages/cache/**/*.{ts,js}"],
|
||||
extends: ["@formbricks/eslint-config/library.js"],
|
||||
parserOptions: {
|
||||
project: "./packages/cache/tsconfig.json",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Bug report
|
||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||
type: bug
|
||||
projects: "formbricks/8"
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Questions
|
||||
url: https://github.com/formbricks/formbricks/discussions
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Feature request
|
||||
description: "Suggest an idea for this project \U0001F680"
|
||||
type: feature
|
||||
projects: "formbricks/21"
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem-description
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/task.yml
vendored
11
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -1,11 +0,0 @@
|
||||
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
|
||||
4
.github/actions/cache-build-web/action.yml
vendored
4
.github/actions/cache-build-web/action.yml
vendored
@@ -62,10 +62,12 @@ runs:
|
||||
shell: bash
|
||||
|
||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||
env:
|
||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||
run: |
|
||||
RANDOM_KEY=$(openssl rand -hex 32)
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||
shell: bash
|
||||
|
||||
- run: |
|
||||
|
||||
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
82
.github/workflows/apply-issue-labels-to-pr.yml
vendored
@@ -1,82 +0,0 @@
|
||||
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
.github/workflows/build-and-push-ecr.yml
vendored
Normal file
168
.github/workflows/build-and-push-ecr.yml
vendored
Normal file
@@ -0,0 +1,168 @@
|
||||
name: Build & Push Docker to ECR
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
deploy_production:
|
||||
description: "Tag image for production deployment"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
deploy_staging:
|
||||
description: "Tag image for staging deployment"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
ECR_REGION: ${{ vars.ECR_REGION }}
|
||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
||||
DOCKERFILE: apps/web/Dockerfile
|
||||
CONTEXT: .
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate image tag input
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
echo "❌ Image tag is required (non-empty)."
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#IMAGE_TAG} > 128 )); then
|
||||
echo "❌ Image tag must be at most 128 characters."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate required variables
|
||||
shell: bash
|
||||
env:
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
ECR_REGION: ${{ env.ECR_REGION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
|
||||
VERSION="${IMAGE_TAG#v}"
|
||||
|
||||
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
|
||||
echo "Original input: ${IMAGE_TAG}"
|
||||
echo "Extracted version: ${VERSION}"
|
||||
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Valid SemVer format detected: ${VERSION}"
|
||||
echo "Updating package.json version to: ${VERSION}"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
||||
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start with the base image tag
|
||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
||||
|
||||
# Add production tag if requested
|
||||
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||
fi
|
||||
|
||||
# Add staging tag if requested
|
||||
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||
fi
|
||||
|
||||
# Output for debugging
|
||||
echo "Generated tags:"
|
||||
echo -e "${TAGS}"
|
||||
|
||||
# Set output for next step (escape newlines for GitHub Actions)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo -e "${TAGS}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||
aws-region: ${{ env.ECR_REGION }}
|
||||
|
||||
- name: Log in to Amazon ECR
|
||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Build and push image (Depot remote builder)
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: ${{ env.CONTEXT }}
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
4
.github/workflows/chromatic.yml
vendored
4
.github/workflows/chromatic.yml
vendored
@@ -6,12 +6,14 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
|
||||
27
.github/workflows/dependency-review.yml
vendored
27
.github/workflows/dependency-review.yml
vendored
@@ -1,27 +0,0 @@
|
||||
# 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
|
||||
89
.github/workflows/deploy-formbricks-cloud.yml
vendored
89
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,54 +4,60 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0.'
|
||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
- staging
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: 'The version of the Docker image to release'
|
||||
description: "The version of the Docker image to release"
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
description: 'The repository to use for the Docker image'
|
||||
description: "The repository to use for the Docker image"
|
||||
required: false
|
||||
type: string
|
||||
default: 'ghcr.io/formbricks/formbricks'
|
||||
default: "ghcr.io/formbricks/formbricks"
|
||||
ENVIRONMENT:
|
||||
description: 'The environment to deploy to'
|
||||
description: "The environment to deploy to"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
helmfile-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
args: --accept-routes
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
@@ -65,9 +71,9 @@ jobs:
|
||||
env:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
||||
name: Deploy Formbricks Cloud Production
|
||||
if: inputs.ENVIRONMENT == 'production'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -75,7 +81,7 @@ jobs:
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -83,16 +89,16 @@ jobs:
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
||||
name: Deploy Formbricks Cloud Staging
|
||||
if: inputs.ENVIRONMENT == 'staging'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||
with:
|
||||
helmfile-version: 'v1.0.0'
|
||||
helmfile-version: "v1.0.0"
|
||||
helm-plugins: >
|
||||
https://github.com/databus23/helm-diff,
|
||||
https://github.com/jkroepke/helm-secrets
|
||||
@@ -100,3 +106,44 @@ jobs:
|
||||
helmfile-auto-init: "false"
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
fi
|
||||
|
||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
|
||||
|
||||
# Prepare JSON payload for selective cache purge
|
||||
json_payload=$(cat << EOF
|
||||
{
|
||||
"hosts": ["$PURGE_HOST"]
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
# Make API call to Cloudflare
|
||||
response=$(curl -s -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/purge_cache" \
|
||||
-H "Authorization: Bearer $CF_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "$json_payload")
|
||||
|
||||
echo "Cloudflare API response: $response"
|
||||
|
||||
# Verify the operation was successful
|
||||
if [[ "$(echo "$response" | jq -r .success)" == "true" ]]; then
|
||||
echo "✅ Successfully purged cache for $PURGE_HOST"
|
||||
else
|
||||
echo "❌ Cloudflare cache purge failed"
|
||||
echo "Error details: $(echo "$response" | jq -r .errors)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
159
.github/workflows/docker-build-validation.yml
vendored
159
.github/workflows/docker-build-validation.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
name: Validate Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Add PostgreSQL service container
|
||||
# Add PostgreSQL and Redis service containers
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
@@ -38,43 +38,98 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
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@v4.2.2
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: false
|
||||
load: true
|
||||
tags: formbricks-test:${{ github.sha }}
|
||||
tags: formbricks-test:${{ env.GITHUB_SHA }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=redis://localhost:6379
|
||||
|
||||
- name: Verify PostgreSQL Connection
|
||||
- name: Verify and Initialize PostgreSQL
|
||||
run: |
|
||||
echo "Verifying PostgreSQL connection..."
|
||||
# Install PostgreSQL client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
|
||||
# Test connection using psql
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||
# Test connection using psql with timeout and proper error handling
|
||||
echo "Testing PostgreSQL connection with 30 second timeout..."
|
||||
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
sleep 2
|
||||
done'; then
|
||||
echo "✅ PostgreSQL connection successful"
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
|
||||
|
||||
# Enable necessary extensions that might be required by migrations
|
||||
echo "Enabling required PostgreSQL extensions..."
|
||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
|
||||
|
||||
else
|
||||
echo "❌ PostgreSQL connection failed after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show network configuration
|
||||
echo "Network configuration:"
|
||||
ip addr show
|
||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||
|
||||
- name: Verify Redis/Valkey Connection
|
||||
run: |
|
||||
echo "Verifying Redis/Valkey connection..."
|
||||
# Install Redis client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y redis-tools
|
||||
|
||||
# Test connection using redis-cli with timeout and proper error handling
|
||||
echo "Testing Redis connection with 30 second timeout..."
|
||||
if timeout 30 bash -c 'until redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; do
|
||||
echo "Waiting for Redis to be ready..."
|
||||
sleep 2
|
||||
done'; then
|
||||
echo "✅ Redis connection successful"
|
||||
redis-cli -h localhost -p 6379 info server | head -5
|
||||
else
|
||||
echo "❌ Redis connection failed after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show network configuration for Redis
|
||||
echo "Redis network configuration:"
|
||||
netstat -tulpn | grep 6379 || echo "No process listening on port 6379"
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
run: |
|
||||
echo "🧪 Testing if the Docker image starts correctly..."
|
||||
|
||||
@@ -86,29 +141,13 @@ jobs:
|
||||
$DOCKER_RUN_ARGS \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||
-d formbricks-test:${{ github.sha }}
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Give it more time to start up
|
||||
echo "Waiting 45 seconds for application to start..."
|
||||
sleep 45
|
||||
|
||||
# Check if the container is running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||
echo "❌ Container failed to start properly!"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Container started successfully!"
|
||||
fi
|
||||
|
||||
# Try connecting to PostgreSQL from inside the container
|
||||
echo "Testing PostgreSQL connection from inside container..."
|
||||
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||
|
||||
# Try to access the health endpoint
|
||||
echo "🏥 Testing /health endpoint..."
|
||||
MAX_RETRIES=10
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
||||
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
|
||||
RETRY_COUNT=0
|
||||
HEALTH_CHECK_SUCCESS=false
|
||||
|
||||
@@ -116,38 +155,32 @@ jobs:
|
||||
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||
|
||||
# Show container logs before each attempt to help debugging
|
||||
if [ $RETRY_COUNT -gt 1 ]; then
|
||||
echo "📋 Current container logs:"
|
||||
docker logs --tail 20 formbricks-test
|
||||
|
||||
# Check if container is still running
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
|
||||
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
|
||||
echo "📋 Container logs:"
|
||||
docker logs formbricks-test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Get detailed curl output for debugging
|
||||
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||
CURL_EXIT_CODE=$?
|
||||
|
||||
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||
echo "Curl output: $HTTP_OUTPUT"
|
||||
|
||||
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||
echo "Status code detected: $STATUS_CODE"
|
||||
|
||||
if [ "$STATUS_CODE" = "200" ]; then
|
||||
echo "✅ Health check successful!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
else
|
||||
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||
fi
|
||||
else
|
||||
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||
|
||||
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
|
||||
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
|
||||
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
|
||||
echo "📋 Recent container logs:"
|
||||
docker logs --tail 10 formbricks-test
|
||||
fi
|
||||
|
||||
echo "Waiting 15 seconds before next attempt..."
|
||||
sleep 15
|
||||
|
||||
# Try health endpoint with shorter timeout for faster polling
|
||||
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
|
||||
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
|
||||
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
|
||||
HEALTH_CHECK_SUCCESS=true
|
||||
break
|
||||
fi
|
||||
|
||||
# Wait 5 seconds before next attempt
|
||||
sleep 5
|
||||
done
|
||||
|
||||
# Show full container logs for debugging
|
||||
@@ -160,7 +193,7 @@ jobs:
|
||||
|
||||
# Exit with failure if health check did not succeed
|
||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
70
.github/workflows/docker-security-scan.yml
vendored
Normal file
70
.github/workflows/docker-security-scan.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
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"
|
||||
68
.github/workflows/e2e.yml
vendored
68
.github/workflows/e2e.yml
vendored
@@ -45,6 +45,22 @@ jobs:
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
valkey:
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
ports:
|
||||
- 6379:6379
|
||||
minio:
|
||||
image: bitnami/minio:2025.7.23-debian-12-r5
|
||||
env:
|
||||
MINIO_ROOT_USER: minioadmin
|
||||
MINIO_ROOT_PASSWORD: minioadmin
|
||||
ports:
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd="curl -fsS http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=20
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -79,10 +95,56 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=minioadmin" >> .env
|
||||
echo "S3_SECRET_KEY=minioadmin" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Install MinIO client (mc)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
|
||||
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
|
||||
MC_BIN="mc.${MC_VERSION}"
|
||||
MC_SUM="${MC_BIN}.sha256sum"
|
||||
|
||||
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
|
||||
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
|
||||
|
||||
sha256sum -c "${MC_SUM}"
|
||||
|
||||
chmod +x "${MC_BIN}"
|
||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Waiting for MinIO to be ready..."
|
||||
ready=0
|
||||
for i in {1..60}; do
|
||||
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
||||
echo "MinIO is up after ${i} seconds"
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "::error::MinIO did not become ready within 60 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mc alias set local http://localhost:9000 minioadmin minioadmin
|
||||
mc mb --ignore-existing local/formbricks-e2e
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
pnpm build --filter=@formbricks/web...
|
||||
@@ -92,6 +154,12 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
|
||||
38
.github/workflows/formbricks-release.yml
vendored
38
.github/workflows/formbricks-release.yml
vendored
@@ -1,20 +1,29 @@
|
||||
name: Build, release & deploy Formbricks images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release stable docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Build & release docker image
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
uses: ./.github/workflows/release-helm-chart.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
@@ -24,6 +33,9 @@ jobs:
|
||||
|
||||
deploy-formbricks-cloud:
|
||||
name: Deploy Helm Chart to Formbricks Cloud
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||
needs:
|
||||
@@ -31,4 +43,16 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
move-stable-tag:
|
||||
name: Move stable tag to release
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/move-stable-tag.yml
|
||||
needs:
|
||||
- docker-build # Ensure release is successful first
|
||||
with:
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
commit_sha: ${{ github.sha }}
|
||||
is_prerelease: ${{ github.event.release.prerelease }}
|
||||
|
||||
96
.github/workflows/move-stable-tag.yml
vendored
Normal file
96
.github/workflows/move-stable-tag.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
||||
name: Move Stable Tag
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "The release tag name (e.g., v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
description: "The commit SHA to point the stable tag to"
|
||||
required: true
|
||||
type: string
|
||||
is_prerelease:
|
||||
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Prevent concurrent stable tag operations to avoid race conditions
|
||||
concurrency:
|
||||
group: move-stable-tag-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
move-stable-tag:
|
||||
name: Move stable tag to release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10 # Prevent hung git operations
|
||||
permissions:
|
||||
contents: write # Required to push tags
|
||||
# Only move stable tag for non-prerelease versions
|
||||
if: ${{ !inputs.is_prerelease }}
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for tag operations
|
||||
|
||||
- name: Validate inputs
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Validate release tag format
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
|
||||
echo "Provided: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate commit SHA format (40 character hex)
|
||||
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
||||
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
|
||||
echo "Provided: $COMMIT_SHA"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Input validation passed"
|
||||
echo "Release tag: $RELEASE_TAG"
|
||||
echo "Commit SHA: $COMMIT_SHA"
|
||||
|
||||
- name: Move stable tag
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Configure git
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
# Verify the commit exists
|
||||
if ! git cat-file -e "$COMMIT_SHA"; then
|
||||
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Move stable tag to the release commit
|
||||
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
|
||||
git tag -f stable "$COMMIT_SHA"
|
||||
git push origin stable --force
|
||||
|
||||
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
|
||||
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"
|
||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
merge_group:
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ jobs:
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -37,6 +39,50 @@ jobs:
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Get reference name and type from environment variables
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
# Create unique timestamped version for testing sourcemap resolution
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name + timestamp
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
VERSION="${BASE_VERSION}-${TIMESTAMP}"
|
||||
echo "Using SemVer tag with timestamp: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
|
||||
echo "Using tag as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease + timestamp
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
|
||||
echo "Using branch as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
@@ -64,6 +110,9 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -82,6 +131,9 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
@@ -96,4 +148,4 @@ jobs:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||
32
.github/workflows/release-docker-github.yml
vendored
32
.github/workflows/release-docker-github.yml
vendored
@@ -7,6 +7,12 @@ name: Docker Release to Github
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
IS_PRERELEASE:
|
||||
description: "Whether this is a prerelease (affects latest tag)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
VERSION:
|
||||
description: release version
|
||||
@@ -20,6 +26,9 @@ env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -45,10 +54,23 @@ jobs:
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
TAG=${{ github.ref }}
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG="$GITHUB_REF"
|
||||
TAG=${TAG#refs/tags/v}
|
||||
|
||||
# Validate the extracted tag format
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Original ref: $GITHUB_REF"
|
||||
echo "Extracted tag: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely add to environment variables
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
@@ -81,6 +103,13 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
@@ -99,6 +128,7 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
|
||||
30
.github/workflows/release-helm-chart.yml
vendored
30
.github/workflows/release-helm-chart.yml
vendored
@@ -26,8 +26,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Extract release version
|
||||
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||
- name: Validate input version
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Validate input version format (expects clean semver without 'v' prefix)
|
||||
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Expected: clean version without 'v' prefix"
|
||||
echo "Provided: $INPUT_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Store validated version in environment variable
|
||||
echo "VERSION<<EOF" >> $GITHUB_ENV
|
||||
echo "$INPUT_VERSION" >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up Helm
|
||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||
@@ -35,15 +50,18 @@ jobs:
|
||||
version: latest
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_ACTOR: ${{ github.actor }}
|
||||
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
||||
|
||||
- name: Install YQ
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Update Chart.yaml with new version
|
||||
run: |
|
||||
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
||||
|
||||
- name: Package Helm chart
|
||||
run: |
|
||||
@@ -51,4 +69,4 @@ jobs:
|
||||
|
||||
- name: Push Helm chart to GitHub Container Registry
|
||||
run: |
|
||||
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||
|
||||
81
.github/workflows/scorecard.yml
vendored
81
.github/workflows/scorecard.yml
vendored
@@ -1,81 +0,0 @@
|
||||
# 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
|
||||
8
.github/workflows/semantic-pull-requests.yml
vendored
8
.github/workflows/semantic-pull-requests.yml
vendored
@@ -56,11 +56,3 @@ jobs:
|
||||
```
|
||||
${{ steps.lint_pr_title.outputs.error_message }}
|
||||
```
|
||||
|
||||
# Delete a previous comment when the issue has been resolved
|
||||
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||
with:
|
||||
header: pr-title-lint-error
|
||||
message: |
|
||||
Thank you for following the naming conventions for pull request titles! 🙏
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -43,6 +43,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
|
||||
@@ -14,12 +14,14 @@ on:
|
||||
paths:
|
||||
- "infra/terraform/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
pull-requests: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -33,7 +35,7 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Tailscale
|
||||
uses: tailscale/github-action@v3
|
||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
with:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -41,6 +41,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -27,10 +27,18 @@ jobs:
|
||||
|
||||
- name: Get source branch name
|
||||
id: branch-name
|
||||
env:
|
||||
RAW_BRANCH: ${{ github.head_ref }}
|
||||
run: |
|
||||
RAW_BRANCH="${{ github.head_ref }}"
|
||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||
|
||||
# Additional validation - ensure branch name is not empty after sanitization
|
||||
if [[ -z "$SOURCE_BRANCH" ]]; then
|
||||
echo "❌ Error: Branch name is empty after sanitization"
|
||||
echo "Original branch: $RAW_BRANCH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Safely add to environment variables using GitHub's recommended method
|
||||
# This prevents environment variable injection attacks
|
||||
|
||||
32
.github/workflows/welcome-new-contributors.yml
vendored
32
.github/workflows/welcome-new-contributors.yml
vendored
@@ -1,32 +0,0 @@
|
||||
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. 😊
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -73,3 +73,5 @@ infra/terraform/.terraform/
|
||||
/.idea/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
|
||||
@@ -31,6 +31,18 @@
|
||||
{
|
||||
"language": "pt-PT",
|
||||
"path": "./apps/web/locales/pt-PT.json"
|
||||
},
|
||||
{
|
||||
"language": "ro-RO",
|
||||
"path": "./apps/web/locales/ro-RO.json"
|
||||
},
|
||||
{
|
||||
"language": "ja-JP",
|
||||
"path": "./apps/web/locales/ja-JP.json"
|
||||
},
|
||||
{
|
||||
"language": "zh-Hans-CN",
|
||||
"path": "./apps/web/locales/zh-Hans-CN.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"eslint.workingDirectories": [{ "mode": "auto" }],
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
|
||||
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
||||
|
||||
## 🛠 Crafting Pull Requests
|
||||
|
||||
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!
|
||||
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.
|
||||
|
||||
## 🚀 Aspiring Features
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ The Open Source Qualtrics Alternative
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
||||
<a href="https://insights.linuxfoundation.org/project/formbricks"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=formbricks"></a>
|
||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
||||
@@ -192,7 +193,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.
|
||||
|
||||
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.
|
||||
- Note: For the time being, we can only facilitate code contributions as an exception.
|
||||
|
||||
## All Thanks To Our Contributors
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* This function is used to resolve the absolute path of a package.
|
||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||
*/
|
||||
const getAbsolutePath = (value: string) => {
|
||||
function getAbsolutePath(value: string): any {
|
||||
return dirname(require.resolve(join(value, "package.json")));
|
||||
};
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
getAbsolutePath("@storybook/addon-essentials"),
|
||||
getAbsolutePath("@chromatic-com/storybook"),
|
||||
getAbsolutePath("@storybook/addon-interactions"),
|
||||
getAbsolutePath("@storybook/addon-a11y"),
|
||||
getAbsolutePath("@storybook/addon-docs"),
|
||||
],
|
||||
framework: {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
import type { Preview } from "@storybook/react";
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TolgeeProvider } from "@tolgee/react";
|
||||
import React from "react";
|
||||
// Import translation data for Storybook
|
||||
import enUSTranslations from "../../web/locales/en-US.json";
|
||||
import "../../web/modules/ui/globals.css";
|
||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||
|
||||
// Create a Storybook-specific Tolgee decorator
|
||||
const withTolgee = (Story: any) => {
|
||||
const tolgee = TolgeeBase().init({
|
||||
tagNewKeys: [], // No branch tagging in Storybook
|
||||
});
|
||||
|
||||
return React.createElement(
|
||||
TolgeeProvider,
|
||||
{
|
||||
tolgee,
|
||||
fallback: "Loading",
|
||||
ssr: {
|
||||
language: "en-US",
|
||||
staticData: {
|
||||
"en-US": enUSTranslations,
|
||||
},
|
||||
},
|
||||
},
|
||||
React.createElement(Story)
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
parameters: {
|
||||
@@ -10,6 +37,7 @@ const preview: Preview = {
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withTolgee],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -14,23 +14,19 @@
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.6.12",
|
||||
"vite": "6.3.5"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.3.6",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta } from "@storybook/blocks";
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import Accessibility from "./assets/accessibility.png";
|
||||
import AddonLibrary from "./assets/addon-library.png";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.21 AS base
|
||||
FROM node:22-alpine3.22 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
@@ -25,26 +25,18 @@ RUN corepack prepare pnpm@9.15.9 --activate
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||
|
||||
# BuildKit secret handling without hardcoded fallback values
|
||||
# This approach relies entirely on secrets passed from GitHub Actions
|
||||
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
|
||||
# Copy the secrets handling script
|
||||
COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
||||
RUN chmod +x /tmp/read-secrets.sh
|
||||
|
||||
# Increase Node.js memory limit as a regular build argument
|
||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||
ARG NODE_OPTIONS="--max_old_space_size=8192"
|
||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
|
||||
# Target architecture - automatically provided by Docker in multi-platform builds
|
||||
# but needs explicit declaration for some build systems (like Depot)
|
||||
ARG TARGETARCH
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -62,10 +54,15 @@ RUN touch apps/web/.env
|
||||
# Install the dependencies
|
||||
RUN pnpm install --ignore-scripts
|
||||
|
||||
# Build the database package first
|
||||
RUN pnpm build --filter=@formbricks/database
|
||||
|
||||
# Build the project using our secret reader script
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
@@ -106,20 +103,8 @@ 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
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
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/packages/database/dist ./packages/database/dist
|
||||
RUN chown -R nextjs:nextjs ./packages/database/dist && chmod -R 755 ./packages/database/dist
|
||||
|
||||
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
|
||||
@@ -130,9 +115,6 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
COPY --from=installer /prisma_version.txt .
|
||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod -R 755 /app/docker/cronjobs
|
||||
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
@@ -142,12 +124,14 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./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
|
||||
|
||||
# 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
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare volume for uploads
|
||||
@@ -158,12 +142,4 @@ VOLUME /home/nextjs/apps/web/uploads/
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
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
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -23,12 +23,12 @@ describe("ConnectWithFormbricks", () => {
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
test("renders waiting state when appSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
publicDomain={webAppUrl}
|
||||
appSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -36,12 +36,12 @@ describe("ConnectWithFormbricks", () => {
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
test("renders success state when appSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
publicDomain={webAppUrl}
|
||||
appSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -53,8 +53,8 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
publicDomain={webAppUrl}
|
||||
appSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -67,8 +67,8 @@ describe("ConnectWithFormbricks", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
publicDomain={webAppUrl}
|
||||
appSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -12,15 +12,15 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
webAppUrl: string;
|
||||
widgetSetupCompleted: boolean;
|
||||
publicDomain: string;
|
||||
appSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
webAppUrl,
|
||||
widgetSetupCompleted,
|
||||
publicDomain,
|
||||
appSetupCompleted,
|
||||
channel,
|
||||
}: ConnectWithFormbricksProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -49,17 +49,17 @@ export const ConnectWithFormbricks = ({
|
||||
<div className="flex w-1/2 flex-col space-y-4">
|
||||
<OnboardingSetupInstructions
|
||||
environmentId={environment.id}
|
||||
webAppUrl={webAppUrl}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
widgetSetupCompleted={widgetSetupCompleted}
|
||||
appSetupCompleted={appSetupCompleted}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
appSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
)}>
|
||||
{widgetSetupCompleted ? (
|
||||
{appSetupCompleted ? (
|
||||
<div>
|
||||
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
@@ -81,9 +81,9 @@ export const ConnectWithFormbricks = ({
|
||||
</div>
|
||||
<Button
|
||||
id="finishOnboarding"
|
||||
variant={widgetSetupCompleted ? "default" : "ghost"}
|
||||
variant={appSetupCompleted ? "default" : "ghost"}
|
||||
onClick={handleFinishOnboarding}>
|
||||
{widgetSetupCompleted
|
||||
{appSetupCompleted
|
||||
? t("environments.connect.finish_onboarding")
|
||||
: t("environments.connect.do_it_later")}
|
||||
<ArrowRight />
|
||||
|
||||
@@ -33,9 +33,9 @@ describe("OnboardingSetupInstructions", () => {
|
||||
// Provide some default props for testing
|
||||
const defaultProps = {
|
||||
environmentId: "env-123",
|
||||
webAppUrl: "https://example.com",
|
||||
publicDomain: "https://example.com",
|
||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||
widgetSetupCompleted: false,
|
||||
appSetupCompleted: false,
|
||||
};
|
||||
|
||||
test("renders HTML tab content by default", () => {
|
||||
|
||||
@@ -18,23 +18,23 @@ const tabs = [
|
||||
|
||||
interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
webAppUrl: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
widgetSetupCompleted: boolean;
|
||||
appSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
webAppUrl,
|
||||
publicDomain,
|
||||
channel,
|
||||
widgetSetupCompleted,
|
||||
appSetupCompleted,
|
||||
}: OnboardingSetupInstructionsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -44,7 +44,7 @@ export const OnboardingSetupInstructions = ({
|
||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||
<script type="text/javascript">
|
||||
!function(){
|
||||
var appUrl = "${webAppUrl}";
|
||||
var appUrl = "${publicDomain}";
|
||||
var environmentId = "${environmentId}";
|
||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||
</script>
|
||||
@@ -57,7 +57,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const OnboardingSetupInstructions = ({
|
||||
if (typeof window !== "undefined") {
|
||||
formbricks.setup({
|
||||
environmentId: "${environmentId}",
|
||||
appUrl: "${webAppUrl}",
|
||||
appUrl: "${publicDomain}",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const OnboardingSetupInstructions = ({
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-connect-copy-code"
|
||||
variant={widgetSetupCompleted ? "secondary" : "default"}
|
||||
variant={appSetupCompleted ? "secondary" : "default"}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -30,6 +30,8 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center py-10">
|
||||
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
|
||||
@@ -39,8 +41,8 @@ const Page = async (props: ConnectPageProps) => {
|
||||
</div>
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
widgetSetupCompleted={environment.appSetupCompleted}
|
||||
publicDomain={publicDomain}
|
||||
appSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -86,6 +86,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { LandingSidebar } from "./landing-sidebar";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
// Mock server actions that this test needs
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Module mocks must be declared before importing the component
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({ t: (key: string) => key, isLoading: false }),
|
||||
}));
|
||||
vi.mock("next-auth/react", () => ({ signOut: vi.fn() }));
|
||||
|
||||
// Mock our useSignOut hook
|
||||
const mockSignOut = vi.fn();
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: () => ({
|
||||
signOut: mockSignOut,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) }));
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) => (
|
||||
@@ -27,22 +45,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
@@ -53,14 +60,7 @@ describe("LandingSidebar component", () => {
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
@@ -70,6 +70,13 @@ describe("LandingSidebar component", () => {
|
||||
const logoutItem = await screen.findByText("common.logout");
|
||||
await userEvent.click(logoutItem);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,53 +3,33 @@
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
organizations: TOrganization[];
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({
|
||||
isMultiOrgEnabled,
|
||||
user,
|
||||
organization,
|
||||
organizations,
|
||||
}: LandingSidebarProps) => {
|
||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslate();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
@@ -60,13 +40,6 @@ export const LandingSidebar = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentOrganizationId = organization?.id;
|
||||
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -79,27 +52,28 @@ export const LandingSidebar = ({
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<>
|
||||
<div>
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="max-w-28 truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||
</>
|
||||
</div>
|
||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<button
|
||||
type="button"
|
||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
||||
aria-haspopup="menu">
|
||||
<ProfileAvatar userId={user.id} />
|
||||
<div className="grow overflow-hidden">
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
@@ -111,7 +85,13 @@ export const LandingSidebar = ({
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<Link
|
||||
key={link.href}
|
||||
id={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
@@ -120,53 +100,20 @@ export const LandingSidebar = ({
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Organization Switch */}
|
||||
|
||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="rounded-lg">
|
||||
<div>
|
||||
<p>{currentOrganizationName}</p>
|
||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentOrganizationId}
|
||||
onValueChange={(organizationId) =>
|
||||
handleEnvironmentChangeByOrganization(organizationId)
|
||||
}>
|
||||
{sortedOrganizations.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={organization.id}
|
||||
className="cursor-pointer rounded-lg"
|
||||
key={organization.id}>
|
||||
{organization.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
PUBLIC_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -89,6 +89,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/environment/service");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
@@ -15,6 +16,7 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
@@ -23,7 +25,6 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_DEVELOPMENT: true,
|
||||
E2E_TESTING: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SURVEY_URL: "http://localhost:3000/survey",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
CRON_SECRET: "mock-cron-secret",
|
||||
DEFAULT_BRAND_COLOR: "#64748b",
|
||||
@@ -98,18 +99,36 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
||||
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
usePathname: vi.fn(() => "/organizations/org1"),
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
@@ -141,6 +160,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -162,6 +182,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -172,10 +193,16 @@ describe("Page component", () => {
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
organization: { id: "org1", billing: { plan: "free" } },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
@@ -187,11 +214,13 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -22,24 +26,38 @@ const Page = async (props) => {
|
||||
|
||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||
|
||||
const { features } = await getEnterpriseLicense();
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
|
||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||
const { isMember } = getAccessFlags(membership?.role);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-row">
|
||||
<LandingSidebar
|
||||
user={user}
|
||||
organization={organization}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizations={organizations}
|
||||
/>
|
||||
<LandingSidebar user={user} organization={organization} />
|
||||
<div className="flex-1">
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
/>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="p-6">
|
||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||
<ProjectAndOrgSwitch
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
projects={[]}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={0}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
isMember={isMember}
|
||||
environments={[]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
|
||||
@@ -34,6 +34,8 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
|
||||
industry: "ind",
|
||||
defaultBrandColor: "#fff",
|
||||
organizationTeams: [],
|
||||
canDoRoleManagement: false,
|
||||
isAccessControlAllowed: false,
|
||||
userProjectsCount: 0,
|
||||
} as any;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
|
||||
industry: TProjectConfigIndustry;
|
||||
defaultBrandColor: string;
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
canDoRoleManagement: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
userProjectsCount: number;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
|
||||
industry,
|
||||
defaultBrandColor,
|
||||
organizationTeams,
|
||||
canDoRoleManagement = false,
|
||||
isAccessControlAllowed = false,
|
||||
userProjectsCount,
|
||||
}: ProjectSettingsProps) => {
|
||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{canDoRoleManagement && userProjectsCount > 0 && (
|
||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="teamIds"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
||||
// Mocks before component import
|
||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
|
||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
|
||||
|
||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
render(element as React.ReactElement);
|
||||
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
|
||||
} as any);
|
||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
||||
|
||||
const element = await Page({ params, searchParams });
|
||||
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 { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
|
||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
|
||||
if (!organizationTeams) {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
industry={industry}
|
||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||
organizationTeams={organizationTeams}
|
||||
canDoRoleManagement={canDoRoleManagement}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
userProjectsCount={projects.length}
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
|
||||
@@ -18,11 +18,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: any) => (
|
||||
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mocks for dependencies
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
@@ -58,7 +53,6 @@ describe("SurveyEditorEnvironmentLayout", () => {
|
||||
render(result);
|
||||
|
||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getEnvironment } from "@/lib/environment/service";
|
||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
@@ -32,7 +31,6 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
||||
user={user}
|
||||
organization={organization}>
|
||||
<div className="flex h-screen flex-col">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||
</div>
|
||||
</EnvironmentIdBaseLayout>
|
||||
|
||||
@@ -26,6 +26,14 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
PUBLIC_URL: "https://public-domain.com",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Contact Page Re-export", () => {
|
||||
|
||||
@@ -4,10 +4,12 @@ import { getOrganization } from "@/lib/organization/service";
|
||||
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||
import { updateUser } from "@/lib/user/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { z } from "zod";
|
||||
@@ -20,62 +22,65 @@ const ZCreateProjectAction = z.object({
|
||||
data: ZProjectUpdateInput,
|
||||
});
|
||||
|
||||
export const createProjectAction = authenticatedActionClient
|
||||
.schema(ZCreateProjectAction)
|
||||
.action(async ({ parsedInput, ctx }) => {
|
||||
const { user } = ctx;
|
||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
||||
withAuditLogging(
|
||||
"created",
|
||||
"project",
|
||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = parsedInput.organizationId;
|
||||
const organizationId = parsedInput.organizationId;
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
await checkAuthorizationUpdated({
|
||||
userId: user.id,
|
||||
organizationId: parsedInput.organizationId,
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZProjectUpdateInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const organization = await getOrganization(organizationId);
|
||||
const organization = await getOrganization(organizationId);
|
||||
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
if (!organization) {
|
||||
throw new Error("Organization not found");
|
||||
}
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||
|
||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||
throw new OperationNotAllowedError("Organization project limit reached");
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
|
||||
if (!isAccessControlAllowed) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||
ctx.auditLoggingCtx.projectId = project.id;
|
||||
ctx.auditLoggingCtx.newObject = project;
|
||||
return project;
|
||||
}
|
||||
|
||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return project;
|
||||
});
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||
import { getSurveysByActionClassId } from "@/lib/survey/service";
|
||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
|
||||
import { z } from "zod";
|
||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
const ZDeleteActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
});
|
||||
|
||||
export const deleteActionClassAction = authenticatedActionClient
|
||||
.schema(ZDeleteActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await deleteActionClass(parsedInput.actionClassId);
|
||||
});
|
||||
|
||||
const ZUpdateActionClassAction = z.object({
|
||||
actionClassId: ZId,
|
||||
updatedAction: ZActionClassInput,
|
||||
});
|
||||
|
||||
export const updateActionClassAction = authenticatedActionClient
|
||||
.schema(ZUpdateActionClassAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
const actionClass = await getActionClass(parsedInput.actionClassId);
|
||||
if (actionClass === null) {
|
||||
throw new ResourceNotFoundError("ActionClass", parsedInput.actionClassId);
|
||||
}
|
||||
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await updateActionClass(
|
||||
actionClass.environmentId,
|
||||
parsedInput.actionClassId,
|
||||
parsedInput.updatedAction
|
||||
);
|
||||
});
|
||||
|
||||
const ZGetActiveInactiveSurveysAction = z.object({
|
||||
actionClassId: ZId,
|
||||
});
|
||||
|
||||
export const getActiveInactiveSurveysAction = authenticatedActionClient
|
||||
.schema(ZGetActiveInactiveSurveysAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromActionClassId(parsedInput.actionClassId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromActionClassId(parsedInput.actionClassId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const surveys = await getSurveysByActionClassId(parsedInput.actionClassId);
|
||||
const response = {
|
||||
activeSurveys: surveys.filter((s) => s.status === "inProgress").map((survey) => survey.name),
|
||||
inactiveSurveys: surveys.filter((s) => s.status !== "inProgress").map((survey) => survey.name),
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
const getLatestStableFbRelease = async (): Promise<string | null> => {
|
||||
try {
|
||||
const res = await fetch("https://api.github.com/repos/formbricks/formbricks/releases");
|
||||
const releases = await res.json();
|
||||
|
||||
if (Array.isArray(releases)) {
|
||||
const latestStableReleaseTag = releases.filter((release) => !release.prerelease)?.[0]
|
||||
?.tag_name as string;
|
||||
if (latestStableReleaseTag) {
|
||||
return latestStableReleaseTag;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestStableFbReleaseAction = actionClient.action(async () => {
|
||||
return await getLatestStableFbRelease();
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ActionActivityTab } from "./ActionActivityTab";
|
||||
import { ActionDetailModal } from "./ActionDetailModal";
|
||||
// Import mocked components
|
||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
||||
<div data-testid="modal-with-tabs">
|
||||
<span data-testid="modal-label">{label}</span>
|
||||
<span data-testid="modal-description">{description}</span>
|
||||
<span data-testid="modal-open">{open.toString()}</span>
|
||||
<button onClick={() => setOpen(false)}>Close</button>
|
||||
{icon}
|
||||
{tabs.map((tab) => (
|
||||
<div key={tab.title}>
|
||||
<h2>{tab.title}</h2>
|
||||
{tab.children}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionSettingsTab", () => ({
|
||||
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
|
||||
}));
|
||||
|
||||
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
ACTION_TYPE_ICON_LOOKUP: {
|
||||
code: <div data-testid="code-icon">Code Icon Mock</div>,
|
||||
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
|
||||
// Add other types if needed by other tests or default props
|
||||
},
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production", // Use string literal as TEnvironmentType is not exported
|
||||
appSetupCompleted: false,
|
||||
} as unknown as TEnvironment;
|
||||
|
||||
const mockActionClass: TActionClass = {
|
||||
id: "action-class-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action",
|
||||
description: "This is a test action",
|
||||
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
|
||||
environmentId: mockEnvironmentId,
|
||||
noCodeConfig: null,
|
||||
key: "test-action-key",
|
||||
};
|
||||
|
||||
const mockActionClasses: TActionClass[] = [mockActionClass];
|
||||
const mockOtherEnvActionClasses: TActionClass[] = [];
|
||||
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
|
||||
|
||||
const defaultProps = {
|
||||
environmentId: mockEnvironmentId,
|
||||
environment: mockEnvironment,
|
||||
open: true,
|
||||
setOpen: mockSetOpen,
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
isReadOnly: false,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
};
|
||||
|
||||
describe("ActionDetailModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Check basic props
|
||||
expect(props.open).toBe(true);
|
||||
expect(props.setOpen).toBe(mockSetOpen);
|
||||
expect(props.label).toBe(mockActionClass.name);
|
||||
expect(props.description).toBe(mockActionClass.description);
|
||||
|
||||
// Check icon data-testid based on the mock for the default 'code' type
|
||||
expect(props.icon).toBeDefined();
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
|
||||
// Check if the correct mocked components are used as children
|
||||
// Access the mocked functions directly
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
|
||||
// Check props passed to child components
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
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);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
// Test with 'noCode' type
|
||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
||||
// Access the mocked component directly
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
|
||||
const activityTabProps = (props.tabs[0].children as any).props;
|
||||
expect(activityTabProps.isReadOnly).toBe(true);
|
||||
|
||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
|
||||
export const ActionClassDataRow = ({
|
||||
actionClass,
|
||||
locale,
|
||||
}: {
|
||||
actionClass: TActionClass;
|
||||
locale: TUserLocale;
|
||||
}) => {
|
||||
return (
|
||||
<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-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-5 w-5 flex-shrink-0 text-slate-500">
|
||||
{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
</div>
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="text-xs text-slate-400">{actionClass.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,255 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
actionClass: TActionClass;
|
||||
actionClasses: TActionClass[];
|
||||
setOpen: (v: boolean) => void;
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const ActionSettingsTab = ({
|
||||
actionClass,
|
||||
actionClasses,
|
||||
setOpen,
|
||||
isReadOnly,
|
||||
}: ActionSettingsTabProps) => {
|
||||
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
|
||||
const router = useRouter();
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const [isUpdatingAction, setIsUpdatingAction] = useState(false);
|
||||
const [isDeletingAction, setIsDeletingAction] = useState(false);
|
||||
|
||||
const actionClassNames = useMemo(
|
||||
() =>
|
||||
actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name),
|
||||
[actionClass.id, actionClasses]
|
||||
);
|
||||
|
||||
const form = useForm<TActionClassInput>({
|
||||
defaultValues: {
|
||||
...restActionClass,
|
||||
},
|
||||
resolver: zodResolver(
|
||||
ZActionClassInput.superRefine((data, ctx) => {
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["name"],
|
||||
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
|
||||
});
|
||||
}
|
||||
})
|
||||
),
|
||||
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { handleSubmit, control } = form;
|
||||
|
||||
const onSubmit = async (data: TActionClassInput) => {
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
setIsUpdatingAction(true);
|
||||
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" &&
|
||||
data.noCodeConfig.elementSelector.cssSelector &&
|
||||
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
||||
) {
|
||||
throw new Error(t("environments.actions.invalid_css_selector"));
|
||||
}
|
||||
|
||||
const updatedData: TActionClassInput = {
|
||||
...data,
|
||||
...(data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" && {
|
||||
noCodeConfig: {
|
||||
...data.noCodeConfig,
|
||||
elementSelector: {
|
||||
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
||||
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
await updateActionClassAction({
|
||||
actionClassId: actionClass.id,
|
||||
updatedAction: updatedData,
|
||||
});
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_updated_successfully"));
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
} finally {
|
||||
setIsUpdatingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAction = async () => {
|
||||
try {
|
||||
setIsDeletingAction(true);
|
||||
await deleteActionClassAction({ actionClassId: actionClass.id });
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingAction(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FormProvider {...form}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="name"
|
||||
disabled={isReadOnly}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionNameSettingsInput">
|
||||
{actionClass.type === "noCode"
|
||||
? t("environments.actions.what_did_your_user_do")
|
||||
: t("environments.actions.display_name")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionNameSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.eg_clicked_download")}
|
||||
isInvalid={!!error?.message}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<FormField
|
||||
control={control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||
{t("common.description")}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
id="actionDescriptionSettingsInput"
|
||||
{...field}
|
||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||
value={field.value ?? ""}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{actionClass.type === "code" ? (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
) : actionClass.type === "noCode" ? (
|
||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isReadOnly ? (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingAction}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
<DeleteDialog
|
||||
open={openDeleteDialog}
|
||||
setOpen={setOpenDeleteDialog}
|
||||
isDeleting={isDeletingAction}
|
||||
deleteWhat={t("common.action")}
|
||||
text={t("environments.actions.delete_action_text")}
|
||||
onDelete={handleDeleteAction}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
|
||||
export const ActionTableHeading = async () => {
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">{t("common.edit")}</span>
|
||||
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
|
||||
<div className="col-span-2 text-center">{t("common.created")}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,142 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
||||
import { AddActionModal } from "./AddActionModal";
|
||||
|
||||
// Mock child components and hooks
|
||||
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
|
||||
CreateNewActionTab: vi.fn(({ setOpen }) => (
|
||||
<div data-testid="create-new-action-tab">
|
||||
<span>CreateNewActionTab Content</span>
|
||||
<button onClick={() => setOpen(false)}>Close from Tab</button>
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, ...props }: any) => (
|
||||
<button onClick={onClick} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Action 1",
|
||||
description: "Description 1",
|
||||
type: "noCode",
|
||||
environmentId: "env1",
|
||||
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
|
||||
} as unknown as TActionClass,
|
||||
];
|
||||
|
||||
const environmentId = "env1";
|
||||
|
||||
describe("AddActionModal", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders the 'Add Action' button initially", () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes correct props to CreateNewActionTab", async () => {
|
||||
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
|
||||
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
|
||||
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(mockedCreateNewActionTab).toHaveBeenCalled();
|
||||
const props = mockedCreateNewActionTab.mock.calls[0][0];
|
||||
expect(props.environmentId).toBe(environmentId);
|
||||
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
|
||||
expect(props.isReadOnly).toBe(false);
|
||||
expect(props.setOpen).toBeInstanceOf(Function);
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
render(
|
||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
||||
);
|
||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
||||
await userEvent.click(addButton);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked CreateNewActionTab's button
|
||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
||||
await userEvent.click(closeFromTabButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
|
||||
interface AddActionModalProps {
|
||||
environmentId: string;
|
||||
actionClasses: TActionClass[];
|
||||
isReadOnly: boolean;
|
||||
}
|
||||
|
||||
export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: AddActionModalProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [newActionClasses, setNewActionClasses] = useState<TActionClass[]>(actionClasses);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setOpen(true)}>
|
||||
{t("common.add_action")}
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<MousePointerClickIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.actions.track_new_user_action")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import Loading from "./loading";
|
||||
|
||||
// Mock child components
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="page-content-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
|
||||
}));
|
||||
|
||||
describe("Loading", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading state correctly", () => {
|
||||
render(<Loading />);
|
||||
|
||||
// Check if mocked components are rendered
|
||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
|
||||
|
||||
// Check for translated table headers
|
||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
|
||||
|
||||
// Check for skeleton elements (presence of animate-pulse class)
|
||||
const skeletonElements = document.querySelectorAll(".animate-pulse");
|
||||
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
|
||||
|
||||
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
|
||||
const pulseDivs = screen.getAllByText((_, element) => {
|
||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
||||
});
|
||||
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<>
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} />
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">{t("common.edit")}</span>
|
||||
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
|
||||
<div className="col-span-2 text-center">{t("common.created")}</div>
|
||||
</div>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
|
||||
<div className="ml-4 text-left">
|
||||
<div className="font-medium text-slate-900">
|
||||
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
<div className="h-2 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default Loading;
|
||||
@@ -1,161 +0,0 @@
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
// Import the component after mocks
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/actionClass/service", () => ({
|
||||
getActionClasses: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/environment/service", () => ({
|
||||
getEnvironments: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/locale", () => ({
|
||||
findMatchingLocale: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||
getEnvironmentAuth: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
|
||||
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
|
||||
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
|
||||
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
|
||||
AddActionModal: () => <div>AddActionModal Mock</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
||||
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||
PageHeader: ({ pageTitle, cta }) => (
|
||||
<div>
|
||||
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockProjectId = "test-project-id";
|
||||
const mockEnvironment = {
|
||||
id: mockEnvironmentId,
|
||||
name: "Test Environment",
|
||||
type: "development",
|
||||
} as unknown as TEnvironment;
|
||||
const mockOtherEnvironment = {
|
||||
id: "other-env-id",
|
||||
name: "Other Environment",
|
||||
type: "production",
|
||||
} as unknown as TEnvironment;
|
||||
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
|
||||
const mockActionClasses = [
|
||||
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
|
||||
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
|
||||
];
|
||||
const mockOtherEnvActionClasses = [
|
||||
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
|
||||
];
|
||||
const mockLocale = "en-US";
|
||||
|
||||
const mockParams = { environmentId: mockEnvironmentId };
|
||||
const mockProps = { params: mockParams };
|
||||
|
||||
describe("Actions Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getActionClasses)
|
||||
.mockResolvedValueOnce(mockActionClasses) // First call for current env
|
||||
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
|
||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("renders the page correctly with actions", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("redirects if isBilling is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: true,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
await Page(mockProps);
|
||||
|
||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
|
||||
});
|
||||
|
||||
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: true,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders AddActionModal CTA if isReadOnly is false", async () => {
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
isReadOnly: false,
|
||||
project: mockProject,
|
||||
isBilling: false,
|
||||
environment: mockEnvironment,
|
||||
} as TEnvironmentAuth);
|
||||
|
||||
const PageComponent = await Page(mockProps);
|
||||
render(PageComponent);
|
||||
|
||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
|
||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable";
|
||||
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
|
||||
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
|
||||
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { getEnvironments } from "@/lib/environment/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Actions",
|
||||
};
|
||||
|
||||
const Page = async (props) => {
|
||||
const params = await props.params;
|
||||
|
||||
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
|
||||
|
||||
const t = await getTranslate();
|
||||
|
||||
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
const environments = await getEnvironments(project.id);
|
||||
|
||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
||||
|
||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
||||
|
||||
if (isBilling) {
|
||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||
}
|
||||
|
||||
const renderAddActionButton = () => (
|
||||
<AddActionModal
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||
<ActionClassesTable
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
otherEnvActionClasses={otherEnvActionClasses}
|
||||
environmentId={params.environmentId}
|
||||
actionClasses={actionClasses}
|
||||
isReadOnly={isReadOnly}>
|
||||
<ActionTableHeading />
|
||||
{actionClasses.map((actionClass) => (
|
||||
<ActionClassDataRow key={actionClass.id} actionClass={actionClass} locale={locale} />
|
||||
))}
|
||||
</ActionClassesTable>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
|
||||
|
||||
export const ACTION_TYPE_ICON_LOOKUP = {
|
||||
code: <Code2Icon className="h-4 w-4" />,
|
||||
noCode: <MousePointerClickIcon className="h-4 w-4" />,
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -5,22 +7,20 @@ import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import {
|
||||
TOrganization,
|
||||
TOrganizationBilling,
|
||||
TOrganizationBillingPlanLimits,
|
||||
} from "@formbricks/types/organizations";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
@@ -31,16 +31,12 @@ vi.mock("@/lib/environment/service", () => ({
|
||||
}));
|
||||
vi.mock("@/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getUserProjects: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
@@ -49,13 +45,33 @@ vi.mock("@/lib/membership/utils", () => ({
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getOrganizationProjectsLimit: vi.fn(),
|
||||
getAccessControlPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||
getProjectPermissionByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||
getTeamsByOrganizationId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: async () => (key: string) => key,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
|
||||
getOrganizationsByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
|
||||
getProjectsByUserId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
organization: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
let mockIsFormbricksCloud = false;
|
||||
let mockIsDevelopment = false;
|
||||
@@ -71,15 +87,17 @@ vi.mock("@/lib/constants", () => ({
|
||||
|
||||
// Mock components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||
MainNavigation: () => <div data-testid="main-navigation">MainNavigation</div>,
|
||||
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
|
||||
<div data-testid="main-navigation">
|
||||
MainNavigation
|
||||
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
||||
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||
}));
|
||||
@@ -99,23 +117,20 @@ const mockUser = {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
imageUrl: "",
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
notificationSettings: { alert: {} },
|
||||
} as unknown as TUser;
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Org",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
||||
} as unknown as TOrganizationBilling,
|
||||
plan: "free",
|
||||
limits: {},
|
||||
},
|
||||
} as unknown as TOrganization;
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
@@ -156,6 +171,17 @@ const mockProjectPermission = {
|
||||
role: "admin",
|
||||
} as any;
|
||||
|
||||
const mockOrganizationTeams = [
|
||||
{
|
||||
id: "team-1",
|
||||
name: "Development Team",
|
||||
},
|
||||
{
|
||||
id: "team-2",
|
||||
name: "Marketing Team",
|
||||
},
|
||||
];
|
||||
|
||||
const mockSession: Session = {
|
||||
user: {
|
||||
id: "user-1",
|
||||
@@ -167,15 +193,19 @@ describe("EnvironmentLayout", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
|
||||
{ id: mockOrganization.id, name: mockOrganization.name },
|
||||
]);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
|
||||
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
|
||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
||||
mockIsDevelopment = false;
|
||||
mockIsFormbricksCloud = false;
|
||||
});
|
||||
@@ -214,33 +244,6 @@ describe("EnvironmentLayout", () => {
|
||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||
mockIsDevelopment = true;
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||
mockIsFormbricksCloud = true;
|
||||
vi.resetModules();
|
||||
@@ -288,6 +291,84 @@ describe("EnvironmentLayout", () => {
|
||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles empty organizationTeams array", async () => {
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||
});
|
||||
|
||||
test("handles null organizationTeams", async () => {
|
||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||
});
|
||||
|
||||
test("handles isAccessControlAllowed false", async () => {
|
||||
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
active: false,
|
||||
isPendingDowngrade: false,
|
||||
features: { isMultiOrgEnabled: false },
|
||||
lastChecked: new Date(),
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
}));
|
||||
const { EnvironmentLayout } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||
);
|
||||
render(
|
||||
await EnvironmentLayout({
|
||||
environmentId: "env-1",
|
||||
session: mockSession,
|
||||
children: <div>Child Content</div>,
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
test("throws error if user not found", async () => {
|
||||
vi.mocked(getUser).mockResolvedValue(null);
|
||||
vi.resetModules();
|
||||
@@ -349,7 +430,7 @@ describe("EnvironmentLayout", () => {
|
||||
});
|
||||
|
||||
test("throws error if projects, environments or organizations not found", async () => {
|
||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
|
||||
vi.resetModules();
|
||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
@@ -8,14 +10,14 @@ import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
getOrganizationsByUserId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -48,17 +50,22 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
throw new Error(t("common.environment_not_found"));
|
||||
}
|
||||
|
||||
const [projects, environments] = await Promise.all([
|
||||
getUserProjects(user.id, organization.id),
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
if (!currentUserMembership) {
|
||||
throw new Error(t("common.membership_not_found"));
|
||||
}
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
|
||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
||||
getProjectsByUserId(user.id, currentUserMembership),
|
||||
getEnvironments(environment.projectId),
|
||||
getAccessControlPermission(organization.billing.plan),
|
||||
]);
|
||||
|
||||
if (!projects || !environments || !organizations) {
|
||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||
const membershipRole = currentUserMembership?.role;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
|
||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||
@@ -83,10 +90,17 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
|
||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||
|
||||
// Find the current project from the projects array
|
||||
const project = projects.find((p) => p.id === environment.projectId);
|
||||
if (!project) {
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||
<DevEnvironmentBanner environment={environment} />
|
||||
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<LimitsReachedBanner
|
||||
organization={organization}
|
||||
@@ -101,30 +115,35 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||
active={active}
|
||||
environmentId={environment.id}
|
||||
locale={user.locale}
|
||||
/>
|
||||
|
||||
<div className="flex h-full">
|
||||
<MainNavigation
|
||||
environment={environment}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
projects={projects}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
user={user}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membershipRole}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isLicenseActive={active}
|
||||
/>
|
||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
currentOrganizationId={organization.id}
|
||||
organizations={organizations}
|
||||
currentProjectId={project.id}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isLicenseActive={active}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
<div className="mt-14">{children}</div>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,26 @@
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
// Mock server actions that this test needs
|
||||
vi.mock("@/modules/auth/actions/sign-out", () => ({
|
||||
logSignOutAction: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: vi.fn() })),
|
||||
@@ -18,7 +29,10 @@ vi.mock("next/navigation", () => ({
|
||||
vi.mock("next-auth/react", () => ({
|
||||
signOut: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
||||
}));
|
||||
vi.mock("@/modules/projects/settings/(setup)/app-connection/actions", () => ({
|
||||
getLatestStableFbReleaseAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
@@ -37,13 +51,6 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||
}));
|
||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||
Project Switcher
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||
}));
|
||||
@@ -86,13 +93,12 @@ const mockUser = {
|
||||
id: "user1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
imageUrl: "http://example.com/avatar.png",
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
||||
notificationSettings: { alert: {} },
|
||||
role: "project_manager",
|
||||
objective: "other",
|
||||
} as unknown as TUser;
|
||||
@@ -132,6 +138,7 @@ const defaultProps = {
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
isAccessControlAllowed: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
@@ -152,13 +159,11 @@ describe("MainNavigation", () => {
|
||||
|
||||
test("renders expanded by default and collapses on toggle", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const projectSwitcher = screen.getByTestId("project-switcher");
|
||||
// Assuming the toggle button is the only one initially without an accessible name
|
||||
// A more specific selector like data-testid would be better if available.
|
||||
const toggleButton = screen.getByRole("button", { name: "" });
|
||||
|
||||
// Check initial state (expanded)
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
// Check localStorage is not set initially after clear()
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||
@@ -169,7 +174,6 @@ describe("MainNavigation", () => {
|
||||
// Check state after first toggle (collapsed)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes true
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||
});
|
||||
@@ -184,7 +188,6 @@ describe("MainNavigation", () => {
|
||||
// Check state after second toggle (expanded)
|
||||
await waitFor(() => {
|
||||
// Check that the attribute eventually becomes false
|
||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||
// Check that localStorage is updated
|
||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||
});
|
||||
@@ -194,16 +197,12 @@ describe("MainNavigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("renders correct active navigation link", () => {
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
|
||||
// Check if the parent li has the active class styling
|
||||
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
|
||||
});
|
||||
|
||||
test("renders user dropdown and handles logout", async () => {
|
||||
vi.mocked(signOut).mockResolvedValue({ url: "/auth/login" });
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Find the avatar and get its parent div which acts as the trigger
|
||||
@@ -216,60 +215,26 @@ describe("MainNavigation", () => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||
|
||||
const logoutButton = screen.getByText("common.logout");
|
||||
await userEvent.click(logoutButton);
|
||||
|
||||
expect(signOut).toHaveBeenCalledWith({ redirect: false, callbackUrl: "/auth/login" });
|
||||
expect(mockSignOut).toHaveBeenCalledWith({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: "org1",
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/auth/login");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||
await userEvent.click(org2Item);
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||
});
|
||||
|
||||
test("opens create organization modal", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||
await userEvent.click(createOrgButton);
|
||||
|
||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides new version banner for members or if no new version", async () => {
|
||||
// Test for member
|
||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||
@@ -297,15 +262,25 @@ describe("MainNavigation", () => {
|
||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows billing link and hides license link in cloud", async () => {
|
||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Wait for dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||
// Test basic navigation structure is rendered (aside element with complementary role)
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles no organizationTeams", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Test that navigation renders correctly with no teams
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles isAccessControlAllowed false", () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
// Test that navigation renders correctly with access control disabled
|
||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,48 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlocksIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
CreditCardIcon,
|
||||
KeyIcon,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -50,52 +35,40 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
organizations: TOrganization[];
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
projects: { id: string; name: string }[];
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
environment,
|
||||
organizations,
|
||||
organization,
|
||||
user,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
organizationProjectsLimit,
|
||||
isLicenseActive,
|
||||
isDevelopment,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
|
||||
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
|
||||
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const project = projects.find((project) => project.id === environment.projectId);
|
||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||
|
||||
const isOwnerOrManager = isManager || isOwner;
|
||||
const isPricingDisabled = isMember;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
@@ -116,40 +89,11 @@ export const MainNavigation = ({
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organization && organization.name !== "") {
|
||||
setCurrentOrganizationName(organization.name);
|
||||
setCurrentOrganizationId(organization.id);
|
||||
// Auto collapse project navbar on org and account settings
|
||||
if (pathname?.includes("/settings")) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}, [organization]);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
const sortedProjects = useMemo(() => {
|
||||
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
||||
|
||||
const groupedProjects = projects.reduce(
|
||||
(acc, project) => {
|
||||
const channel = project.config.channel;
|
||||
const key = channel !== null ? channel : "null";
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(project);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof projects>
|
||||
);
|
||||
|
||||
Object.keys(groupedProjects).forEach((channel) => {
|
||||
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
|
||||
}, [projects]);
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
@@ -166,18 +110,6 @@ export const MainNavigation = ({
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
},
|
||||
{
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
href: `/environments/${environment.id}/integrations`,
|
||||
icon: BlocksIcon,
|
||||
isActive: pathname?.includes("/integrations"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/project/general`,
|
||||
@@ -194,29 +126,18 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
icon: UserCircleIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.organization"),
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.license"),
|
||||
href: `/environments/${environment.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.share_feedback"),
|
||||
href: "https://github.com/formbricks/formbricks/issues",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -226,7 +147,7 @@ export const MainNavigation = ({
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${packageJson.version}`;
|
||||
|
||||
if (currentVersionTag !== latestVersionTag) {
|
||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
}
|
||||
}
|
||||
@@ -242,8 +163,7 @@ export const MainNavigation = ({
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
||||
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
@@ -309,22 +229,6 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Project Switch */}
|
||||
{!isBilling && (
|
||||
<ProjectSwitcher
|
||||
environmentId={environment.id}
|
||||
projects={sortedProjects}
|
||||
project={project}
|
||||
isCollapsed={isCollapsed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTextVisible={isTextVisible}
|
||||
organization={organization}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
@@ -333,29 +237,27 @@ export const MainNavigation = ({
|
||||
id="userDropdownTrigger"
|
||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-row items-center space-x-3",
|
||||
isCollapsed ? "pl-2" : "pl-4"
|
||||
"flex cursor-pointer flex-row items-center gap-3",
|
||||
isCollapsed ? "justify-center px-2" : "px-4"
|
||||
)}>
|
||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||
<ProfileAvatar userId={user.id} />
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<>
|
||||
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||
<div
|
||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||
<p
|
||||
title={user?.email}
|
||||
className={cn(
|
||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="max-w-28 truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
</p>
|
||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||
<ChevronRightIcon
|
||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,81 +271,41 @@ export const MainNavigation = ({
|
||||
align="end">
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map(
|
||||
(link) =>
|
||||
!link.hidden && (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
className="flex w-full items-center"
|
||||
key={link.label}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
</DropdownMenuItem>
|
||||
</Link>
|
||||
))}
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const route = await signOut({ redirect: false, callbackUrl: "/auth/login" });
|
||||
router.push(route.url);
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
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} />}>
|
||||
{t("common.logout")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{/* Organization Switch */}
|
||||
|
||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="rounded-lg">
|
||||
<div>
|
||||
<p>{currentOrganizationName}</p>
|
||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||
</div>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||
<DropdownMenuRadioGroup
|
||||
value={currentOrganizationId}
|
||||
onValueChange={(organizationId) =>
|
||||
handleEnvironmentChangeByOrganization(organizationId)
|
||||
}>
|
||||
{sortedOrganizations.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
value={organization.id}
|
||||
className="cursor-pointer rounded-lg"
|
||||
key={organization.id}>
|
||||
{organization.name}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setShowCreateOrganizationModal(true)}
|
||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
<CreateOrganizationModal
|
||||
open={showCreateOrganizationModal}
|
||||
setOpen={(val) => setShowCreateOrganizationModal(val)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("PosthogIdentify", () => {
|
||||
{
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "engineer",
|
||||
objective: "increase_conversion",
|
||||
} as TUser
|
||||
}
|
||||
environmentId="env-456"
|
||||
@@ -57,8 +55,6 @@ describe("PosthogIdentify", () => {
|
||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "engineer",
|
||||
objective: "increase_conversion",
|
||||
});
|
||||
|
||||
// environment + organization groups
|
||||
@@ -142,8 +138,6 @@ describe("PosthogIdentify", () => {
|
||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: undefined,
|
||||
objective: undefined,
|
||||
});
|
||||
// No environmentId or organizationId => no group calls
|
||||
expect(mockGroup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -32,8 +32,6 @@ export const PosthogIdentify = ({
|
||||
posthog.identify(session.user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
objective: user.objective,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
@@ -56,8 +54,6 @@ export const PosthogIdentify = ({
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
user.role,
|
||||
user.objective,
|
||||
isPosthogEnabled,
|
||||
]);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const TestComponent = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
|
||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||
@@ -44,7 +44,7 @@ const TestComponent = () => {
|
||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||
},
|
||||
],
|
||||
onlyComplete: true,
|
||||
responseStatus: "complete",
|
||||
})
|
||||
}>
|
||||
Update Filter
|
||||
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
|
||||
const updateButton = screen.getByText("Update Filter");
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ export interface FilterValue {
|
||||
};
|
||||
}
|
||||
|
||||
export type TResponseStatus = "all" | "complete" | "partial";
|
||||
|
||||
export interface SelectedFilterValue {
|
||||
filter: FilterValue[];
|
||||
onlyComplete: boolean;
|
||||
responseStatus: TResponseStatus;
|
||||
}
|
||||
|
||||
interface SelectedFilterOptions {
|
||||
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
// state holds the filter selected value
|
||||
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
// state holds all the options of the responses fetched
|
||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
||||
});
|
||||
setSelectedFilter({
|
||||
filter: [],
|
||||
onlyComplete: false,
|
||||
responseStatus: "all",
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlBar } from "./TopControlBar";
|
||||
|
||||
// Mock the child component
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
||||
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
||||
}));
|
||||
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "env1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "proj1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [
|
||||
mockEnvironment,
|
||||
{ ...mockEnvironment, id: "env2", type: "development" },
|
||||
];
|
||||
|
||||
const mockMembershipRole: TOrganizationRole = "owner";
|
||||
const mockProjectPermission = "manage";
|
||||
|
||||
describe("TopControlBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders correctly and passes props to TopControlButtons", () => {
|
||||
render(
|
||||
<TopControlBar
|
||||
environment={mockEnvironment}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={mockMembershipRole}
|
||||
projectPermission={mockProjectPermission}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the main div is rendered
|
||||
const mainDiv = screen.getByTestId("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,33 +1,62 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
"use client";
|
||||
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface SideBarProps {
|
||||
environment: TEnvironment;
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({
|
||||
environment,
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: SideBarProps) => {
|
||||
}: TopControlBarProps) => {
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||
<div className="shadow-xs z-10">
|
||||
<div className="flex w-fit items-center space-x-2 py-2">
|
||||
<TopControlButtons
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<ProjectAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TopControlButtons } from "./TopControlButtons";
|
||||
|
||||
// Mock dependencies
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||
getTeamPermissionFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
|
||||
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/button", () => ({
|
||||
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
|
||||
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
|
||||
return (
|
||||
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
|
||||
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
BugIcon: () => <div data-testid="bug-icon" />,
|
||||
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
|
||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||
<a href={href} target={target} data-testid="link-mock">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockEnvironmentDev: TEnvironment = {
|
||||
id: "dev-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironmentProd: TEnvironment = {
|
||||
id: "prod-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "production",
|
||||
projectId: "project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||
|
||||
describe("TopControlButtons", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks for access flags
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: false,
|
||||
isMember: false,
|
||||
isBilling: false,
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: false,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const renderComponent = (
|
||||
membershipRole?: TOrganizationRole,
|
||||
projectPermission: any = null,
|
||||
isBilling = false,
|
||||
hasReadAccess = false
|
||||
) => {
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isMember: membershipRole === "member",
|
||||
isBilling: isBilling,
|
||||
isOwner: membershipRole === "owner",
|
||||
} as any);
|
||||
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||
hasReadAccess: hasReadAccess,
|
||||
} as any);
|
||||
|
||||
return render(
|
||||
<TopControlButtons
|
||||
environment={mockEnvironmentDev}
|
||||
environments={mockEnvironments}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
test("renders correctly for Owner role", async () => {
|
||||
renderComponent("owner");
|
||||
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
|
||||
// Check link
|
||||
const link = screen.getByTestId("link-mock");
|
||||
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
|
||||
// Click account button
|
||||
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
|
||||
await userEvent.click(accountButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
|
||||
});
|
||||
|
||||
// Click new survey button
|
||||
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
|
||||
await userEvent.click(newSurveyButton!);
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
|
||||
});
|
||||
});
|
||||
|
||||
test("hides EnvironmentSwitch for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
|
||||
});
|
||||
|
||||
test("hides New Survey button for Billing role", () => {
|
||||
renderComponent(undefined, null, true); // isBilling = true
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides New Survey button for read-only Member", () => {
|
||||
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
|
||||
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows New Survey button for Member with write access", () => {
|
||||
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
|
||||
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface TopControlButtonsProps {
|
||||
environment: TEnvironment;
|
||||
environments: TEnvironment[];
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlButtons = ({
|
||||
environment,
|
||||
environments,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: TopControlButtonsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const router = useRouter();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
|
||||
return (
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-fit w-fit bg-slate-50 p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/settings/profile`);
|
||||
}}>
|
||||
<CircleUserIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-fit w-fit p-1"
|
||||
onClick={() => {
|
||||
router.push(`/environments/${environment.id}/surveys/templates`);
|
||||
}}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
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}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
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} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
// 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}
|
||||
currentEnvironment={mockProductionEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("handles empty environments array gracefully", () => {
|
||||
render(<EnvironmentBreadcrumb environments={[]} currentEnvironment={mockProductionEnvironment} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByText("production")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"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,
|
||||
currentEnvironment,
|
||||
}: {
|
||||
environments: { id: string; type: string }[];
|
||||
currentEnvironment: { id: string; type: string };
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleEnvironmentChange = (environmentId: string) => {
|
||||
if (environmentId === currentEnvironment.id) return;
|
||||
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.type === currentEnvironment.type}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,560 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { OrganizationBreadcrumb } from "./organization-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
||||
CreateOrganizationModal: ({ open, setOpen }: any) =>
|
||||
open ? (
|
||||
<div data-testid="create-organization-modal">
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Close Modal
|
||||
</button>
|
||||
Create Organization Modal
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}
|
||||
role="button"
|
||||
tabIndex={0}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
BuildingIcon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "building-header-icon" : "building-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>Building Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronRight Icon</title>
|
||||
</svg>
|
||||
),
|
||||
PlusIcon: ({ className }: any) => (
|
||||
<svg data-testid="plus-icon" className={className}>
|
||||
<title>Plus Icon</title>
|
||||
</svg>
|
||||
),
|
||||
SettingsIcon: ({ className }: any) => (
|
||||
<svg data-testid="settings-icon" className={className}>
|
||||
<title>Settings Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("OrganizationBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockOrganization1: TOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Organization 1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "free",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockOrganization2: TOrganization = {
|
||||
id: "org-2",
|
||||
name: "Test Organization 2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "startup",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: true,
|
||||
};
|
||||
|
||||
const mockOrganizations = [mockOrganization1, mockOrganization2];
|
||||
const currentEnvironmentId = "env-123";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Single Organization Setup", () => {
|
||||
test("renders organization breadcrumb without dropdown for single org", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Organization 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows organization settings without organization switcher", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.choose_organization")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multi Organization Setup", () => {
|
||||
test("renders organization breadcrumb with dropdown for multi org", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Organization 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("shows chevron icons correctly", () => {
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show chevron right when closed
|
||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows chevron down when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("renders organization selector in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
expect(checkboxItems.length).toBeGreaterThanOrEqual(2); // Organizations + create new option + settings
|
||||
});
|
||||
|
||||
test("handles organization change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const org2Option = checkboxItems.find((item) => item.textContent?.includes("Test Organization 2"));
|
||||
|
||||
expect(org2Option).toBeInTheDocument();
|
||||
await user.click(org2Option!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/organizations/org-2/");
|
||||
});
|
||||
|
||||
test("shows create new organization option when multi org is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
expect(createOrgOption).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens create organization modal when clicking create new option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
await user.click(createOrgOption);
|
||||
|
||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides create new organization option when multi org is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.queryByText("common.create_new_organization")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Settings", () => {
|
||||
test("renders all organization settings options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.general")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.teams")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles navigation to organization settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const generalOption = screen.getByText("common.general");
|
||||
await user.click(generalOption);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${currentEnvironmentId}/settings/general`);
|
||||
});
|
||||
|
||||
test("marks current settings page as checked", async () => {
|
||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/settings/teams");
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const teamsOption = checkboxItems.find((item) => item.textContent?.includes("common.teams"));
|
||||
|
||||
expect(teamsOption).toBeInTheDocument();
|
||||
expect(teamsOption).toHaveAttribute("data-checked", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles single organization with multi org enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should still show organization selector since multi org is enabled
|
||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.create_new_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows separator between organization switcher and settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("closes create organization modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={mockOrganization1.id}
|
||||
organizations={mockOrganizations}
|
||||
isMultiOrgEnabled={true}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={true}
|
||||
isMember={false}
|
||||
isOwnerOrManager={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
||||
await user.click(createOrgOption);
|
||||
|
||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("create-organization-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
"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 * as Sentry from "@sentry/nextjs";
|
||||
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";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
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) {
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === currentOrganizationId) return;
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
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: "development",
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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("Environments Count: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"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]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
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}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
/>
|
||||
)}
|
||||
{showEnvironmentBreadcrumb && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
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(),
|
||||
usePathname: vi.fn(() => "/environments/env-123/project/general"),
|
||||
}));
|
||||
|
||||
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>,
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
}));
|
||||
|
||||
// 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>
|
||||
),
|
||||
CogIcon: ({ className }: any) => (
|
||||
<svg data-testid="cog-icon" className={className}>
|
||||
<title>Cog Icon</title>
|
||||
</svg>
|
||||
),
|
||||
SettingsIcon: ({ className }: any) => (
|
||||
<svg data-testid="settings-icon" className={className}>
|
||||
<title>Settings 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,
|
||||
isEnvironmentBreadcrumbVisible: 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.getAllByTestId("dropdown-group")).toHaveLength(2); // Projects group and settings group
|
||||
});
|
||||
|
||||
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,
|
||||
isEnvironmentBreadcrumbVisible: 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,
|
||||
isEnvironmentBreadcrumbVisible: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
"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,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
currentOrganizationId: string;
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
}
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
}: 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 pathname = usePathname();
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === currentProjectId) return;
|
||||
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} />
|
||||
) : (
|
||||
isEnvironmentBreadcrumbVisible && <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" strokeWidth={1.5} />
|
||||
{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" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.project_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={pathname.includes(setting.id)}
|
||||
onClick={() => router.push(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
{openLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openLimitModal}
|
||||
setOpen={setOpenLimitModal}
|
||||
buttons={LimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
organizationId={currentOrganizationId}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user