mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 22:20:52 -06:00
Compare commits
248 Commits
empty_list
...
pr-6518-du
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4512cc86e | ||
|
|
0c77da1456 | ||
|
|
2db309dd1f | ||
|
|
feee22b5c3 | ||
|
|
a5433f6748 | ||
|
|
557f14bab8 | ||
|
|
fdba260301 | ||
|
|
f0c4c7000c | ||
|
|
ccd401c0a5 | ||
|
|
e9bf0b7dd9 | ||
|
|
9e322a4fff | ||
|
|
cf36b59fd6 | ||
|
|
764b8ec260 | ||
|
|
ac5d1e651e | ||
|
|
83b6f71867 | ||
|
|
26203640a4 | ||
|
|
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 |
@@ -1,12 +1,8 @@
|
||||
---
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
@@ -16,6 +12,7 @@ This rule provides a reference to the Formbricks database structure. For the mos
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
@@ -29,6 +26,7 @@ Organization
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
@@ -37,17 +35,22 @@ The schema.prisma file contains all model definitions, relationships, enums, and
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
|
||||
Some models use soft deletion patterns:
|
||||
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
|
||||
Configured cascade relationships:
|
||||
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
@@ -55,6 +58,7 @@ Configured cascade relationships:
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
@@ -62,40 +66,40 @@ const survey = await prisma.survey.findUnique({
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true }
|
||||
}
|
||||
}
|
||||
select: { responses: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true
|
||||
}
|
||||
}
|
||||
}
|
||||
attributeKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
|
||||
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
||||
- The page should never start with H1 headline, because it's already part of the template.
|
||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the @Enterprise Edition.
|
||||
</Note>
|
||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
|
||||
@@ -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)
|
||||
74
.cursor/rules/overview.mdc
Normal file
74
.cursor/rules/overview.mdc
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
### Formbricks Monorepo Overview
|
||||
|
||||
- **Project**: Formbricks — open‑source survey and experience management platform. Repo: [formbricks/formbricks](https://github.com/formbricks/formbricks)
|
||||
- **Monorepo**: Turborepo + pnpm workspaces. Root configs: [package.json](mdc:package.json), [turbo.json](mdc:turbo.json)
|
||||
- **Core app**: Next.js app in `apps/web` with Prisma, Auth.js, TailwindCSS, Vitest, Playwright. Enterprise modules live in [apps/web/modules/ee](mdc:apps/web/modules/ee)
|
||||
- **Datastores**: PostgreSQL + Redis. Local dev via [docker-compose.dev.yml](mdc:docker-compose.dev.yml); Prisma schema at [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- **Docs & Ops**: Docs in `docs/` (Mintlify), Helm in `helm-chart/`, IaC in `infra/`
|
||||
|
||||
### Apps
|
||||
|
||||
- **apps/web**: Next.js product application (API, UI, SSO, i18n, emails, uploads, integrations)
|
||||
- **apps/storybook**: Storybook for UI components; a11y addon + Vite builder
|
||||
|
||||
### Packages
|
||||
|
||||
- **@formbricks/database** (`packages/database`): Prisma schema, DB scripts, migrations, data layer
|
||||
- **@formbricks/js-core** (`packages/js-core`): Core runtime for web embed / async loader
|
||||
- **@formbricks/surveys** (`packages/surveys`): Embeddable survey rendering and helpers
|
||||
- **@formbricks/logger** (`packages/logger`): Shared logging (pino) + Zod types
|
||||
- **@formbricks/types** (`packages/types`): Shared types (Zod, Prisma clients)
|
||||
- **@formbricks/i18n-utils** (`packages/i18n-utils`): i18n helpers and build output
|
||||
- **@formbricks/eslint-config** (`packages/config-eslint`): Central ESLint config (Next, TS, Vitest, Prettier)
|
||||
- **@formbricks/config-typescript** (`packages/config-typescript`): Central TS config and types
|
||||
- **@formbricks/vite-plugins** (`packages/vite-plugins`): Internal Vite plugins
|
||||
- **packages/android, packages/ios**: Native SDKs (built with platform toolchains)
|
||||
|
||||
### Enterprise‑ready by design
|
||||
|
||||
- **Quality & safety**: Strict TypeScript, repo‑wide ESLint + Prettier, lint‑staged + Husky, CI checks, typed env validation
|
||||
- **Security‑first**: Auth.js, SSO/SAML/OIDC, session controls, rate limiting, Sentry, structured logging
|
||||
|
||||
### Accessible by design
|
||||
|
||||
- **UI foundations**: Radix UI, TailwindCSS, Storybook with `@storybook/addon-a11y`, keyboard and screen‑reader‑friendly components
|
||||
|
||||
### Root pnpm commands
|
||||
|
||||
```bash
|
||||
pnpm clean:all # Clean turbo cache, node_modules, lockfile, coverage, out
|
||||
pnpm clean # Clean turbo cache, node_modules, coverage, out
|
||||
pnpm build # Build all packages/apps (turbo)
|
||||
pnpm build:dev # Dev-optimized builds (where supported)
|
||||
pnpm dev # Run all dev servers in parallel
|
||||
pnpm start # Start built apps/services
|
||||
pnpm go # Start DB (docker compose) and run long-running dev tasks
|
||||
pnpm generate # Run generators (e.g., Prisma, API specs)
|
||||
pnpm lint # Lint all
|
||||
pnpm format # Prettier write across repo
|
||||
pnpm test # Unit tests
|
||||
pnpm test:coverage # Unit tests with coverage
|
||||
pnpm test:e2e # Playwright tests
|
||||
pnpm test-e2e:azure # Playwright tests with Azure config
|
||||
pnpm storybook # Run Storybook
|
||||
pnpm db:up # Start local Postgres/Redis via docker compose
|
||||
pnpm db:down # Stop local DB stack
|
||||
pnpm db:start # Project-level DB setup choreography
|
||||
pnpm db:push # Prisma db push (accept data loss in package script)
|
||||
pnpm db:migrate:dev # Apply dev migrations
|
||||
pnpm db:migrate:deploy # Apply prod migrations
|
||||
pnpm fb-migrate-dev # Create DB migration (database package) and prisma generate
|
||||
pnpm tolgee-pull # Pull translation keys for current branch and format
|
||||
```
|
||||
|
||||
### Essentials for every prompt
|
||||
|
||||
- **Tech stack**: Next.js, React 19, TypeScript, Prisma, Zod, TailwindCSS, Turborepo, Vitest, Playwright
|
||||
- **Environments**: See `.env.example`. Many tasks require DB up and env variables set
|
||||
- **Licensing**: Core under AGPLv3; Enterprise code in `apps/web/modules/ee` (included in Docker, unlocked via Enterprise License Key)
|
||||
|
||||
For deeper details, consult per‑package `package.json` and scripts (e.g., [apps/web/package.json](mdc:apps/web/package.json)).
|
||||
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(() => {
|
||||
|
||||
@@ -189,15 +189,11 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
@@ -210,6 +206,8 @@ 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"
|
||||
@@ -217,7 +215,7 @@ UNKEY_ROOT_KEY=
|
||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||
# SESSION_MAX_AGE=86400
|
||||
|
||||
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
|
||||
# Audit logs options. Default 0.
|
||||
# AUDIT_LOG_ENABLED=0
|
||||
# If the ip should be added in the log or not. Default 0
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
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
|
||||
35
.github/workflows/deploy-formbricks-cloud.yml
vendored
35
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -17,8 +17,8 @@ on:
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
- staging
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -37,21 +37,27 @@ on:
|
||||
|
||||
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 }}
|
||||
@@ -83,9 +89,9 @@ 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 }}
|
||||
@@ -101,19 +107,20 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
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: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||
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
|
||||
|
||||
125
.github/workflows/docker-build-validation.yml
vendored
125
.github/workflows/docker-build-validation.yml
vendored
@@ -39,42 +39,68 @@ jobs:
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@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 }}
|
||||
|
||||
- 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: 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 +112,12 @@ 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" \
|
||||
-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 +125,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 +163,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"
|
||||
7
.github/workflows/e2e.yml
vendored
7
.github/workflows/e2e.yml
vendored
@@ -89,6 +89,7 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -102,6 +103,12 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
|
||||
28
.github/workflows/formbricks-release.yml
vendored
28
.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,6 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
|
||||
|
||||
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. 😊
|
||||
@@ -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"
|
||||
|
||||
@@ -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.5",
|
||||
"@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,14 @@ 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=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
# Extract Prisma version
|
||||
@@ -106,20 +102,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
|
||||
@@ -142,12 +126,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 +144,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"]
|
||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -45,22 +45,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("LandingSidebar component", () => {
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
||||
const organization = { id: "o1", name: "orgOne" } as any;
|
||||
const organizations = [
|
||||
{ id: "o2", name: "betaOrg" },
|
||||
{ id: "o1", name: "alphaOrg" },
|
||||
] as any;
|
||||
|
||||
test("renders logo, avatar, and initial modal closed", () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Formbricks logo
|
||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||
@@ -71,14 +60,7 @@ describe("LandingSidebar component", () => {
|
||||
});
|
||||
|
||||
test("clicking logout triggers signOut", async () => {
|
||||
render(
|
||||
<LandingSidebar
|
||||
isMultiOrgEnabled={false}
|
||||
user={user}
|
||||
organization={organization}
|
||||
organizations={organizations}
|
||||
/>
|
||||
);
|
||||
render(<LandingSidebar user={user} organization={organization} />);
|
||||
|
||||
// Open user dropdown by clicking on avatar trigger
|
||||
const trigger = screen.getByTestId("avatar").parentElement;
|
||||
@@ -94,6 +76,7 @@ describe("LandingSidebar component", () => {
|
||||
organizationId: "o1",
|
||||
redirect: true,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,48 +10,27 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
isMultiOrgEnabled: boolean;
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
organizations: TOrganization[];
|
||||
}
|
||||
|
||||
export const LandingSidebar = ({
|
||||
isMultiOrgEnabled,
|
||||
user,
|
||||
organization,
|
||||
organizations,
|
||||
}: LandingSidebarProps) => {
|
||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||
|
||||
const { t } = useTranslate();
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
@@ -61,13 +40,6 @@ export const LandingSidebar = ({
|
||||
},
|
||||
];
|
||||
|
||||
const currentOrganizationId = organization?.id;
|
||||
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
@@ -80,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
|
||||
@@ -112,7 +85,13 @@ export const LandingSidebar = ({
|
||||
{/* Dropdown Items */}
|
||||
|
||||
{dropdownNavigation.map((link) => (
|
||||
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||
<Link
|
||||
key={link.href}
|
||||
id={link.href}
|
||||
href={link.href}
|
||||
target={link.target}
|
||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
className="flex w-full items-center">
|
||||
<DropdownMenuItem>
|
||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||
{link.label}
|
||||
@@ -121,7 +100,6 @@ export const LandingSidebar = ({
|
||||
))}
|
||||
|
||||
{/* Logout */}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOutWithAudit({
|
||||
@@ -130,50 +108,12 @@ export const LandingSidebar = ({
|
||||
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>
|
||||
|
||||
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
@@ -97,20 +99,36 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/getPublicUrl", () => ({
|
||||
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
||||
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
||||
}));
|
||||
vi.mock("@/modules/organization/lib/utils");
|
||||
vi.mock("@/lib/user/service");
|
||||
vi.mock("@/lib/organization/service");
|
||||
vi.mock("@/lib/membership/service");
|
||||
vi.mock("@/tolgee/server");
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||
usePathname: vi.fn(() => "/organizations/org1"),
|
||||
useRouter: vi.fn(() => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the React cache function
|
||||
@@ -142,6 +160,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -163,6 +182,7 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const result = await Page({ params: { organizationId: "org1" } });
|
||||
@@ -173,10 +193,16 @@ describe("Page component", () => {
|
||||
test("renders header and sidebar for authenticated user", async () => {
|
||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||
session: { user: { id: "user1" } },
|
||||
organization: { id: "org1" },
|
||||
organization: { id: "org1", billing: { plan: "free" } },
|
||||
} as any);
|
||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||
organizationId: "org1",
|
||||
userId: "user1",
|
||||
accepted: true,
|
||||
role: "member",
|
||||
} as any);
|
||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||
typeof props === "string" ? props : props.key || ""
|
||||
);
|
||||
@@ -188,11 +214,13 @@ describe("Page component", () => {
|
||||
isPendingDowngrade: false,
|
||||
fallbackLevel: "live",
|
||||
}),
|
||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||
}));
|
||||
const { default: Page } = await import("./page");
|
||||
const element = await Page({ params: { organizationId: "org1" } });
|
||||
render(element as React.ReactElement);
|
||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
|
||||
@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import {
|
||||
getAccessControlPermission,
|
||||
getOrganizationProjectsLimit,
|
||||
getRoleManagementPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||
import { z } from "zod";
|
||||
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
}
|
||||
|
||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||
|
||||
if (!canDoRoleManagement) {
|
||||
if (!isAccessControlAllowed) {
|
||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||
}
|
||||
}
|
||||
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[project.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
|
||||
@@ -24,14 +24,17 @@ export const ActionClassesTable = ({
|
||||
otherEnvActionClasses,
|
||||
otherEnvironment,
|
||||
}: ActionClassesTableProps) => {
|
||||
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
||||
|
||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||
|
||||
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
|
||||
const handleOpenActionDetailModalClick = (
|
||||
e: React.MouseEvent<HTMLButtonElement>,
|
||||
actionClass: TActionClass
|
||||
) => {
|
||||
e.preventDefault();
|
||||
setActiveActionClass(actionClass);
|
||||
setActionDetailModalOpen(true);
|
||||
setIsActionDetailModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -42,7 +45,7 @@ export const ActionClassesTable = ({
|
||||
{actionClasses.length > 0 ? (
|
||||
actionClasses.map((actionClass, index) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
handleOpenActionDetailModalClick(e, actionClass);
|
||||
}}
|
||||
className="w-full"
|
||||
@@ -63,7 +66,7 @@ export const ActionClassesTable = ({
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
open={isActionDetailModalOpen}
|
||||
setOpen={setActionDetailModalOpen}
|
||||
setOpen={setIsActionDetailModalOpen}
|
||||
actionClasses={actionClasses}
|
||||
actionClass={activeActionClass}
|
||||
isReadOnly={isReadOnly}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
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 } from "@formbricks/types/action-classes";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -8,23 +8,40 @@ 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>
|
||||
)),
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
}) =>
|
||||
open ? (
|
||||
<div data-testid="dialog">
|
||||
{children}
|
||||
<button data-testid="dialog-close" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-content">{children}</div>
|
||||
),
|
||||
DialogHeader: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-header">{children}</div>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<p data-testid="dialog-description">{children}</p>
|
||||
),
|
||||
DialogBody: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-body">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ActionActivityTab", () => ({
|
||||
@@ -44,6 +61,22 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useTranslate
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations = {
|
||||
"common.activity": "Activity",
|
||||
"common.settings": "Settings",
|
||||
"common.no_code": "No Code",
|
||||
"common.action": "Action",
|
||||
"common.code": "Code",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockEnvironmentId = "test-env-id";
|
||||
const mockSetOpen = vi.fn();
|
||||
|
||||
@@ -89,58 +122,68 @@ describe("ActionDetailModal", () => {
|
||||
vi.clearAllMocks(); // Clear mocks after each test
|
||||
});
|
||||
|
||||
test("renders ModalWithTabs with correct props", () => {
|
||||
test("renders correctly when open", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("This is a test action");
|
||||
expect(screen.getByTestId("code-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Activity")).toBeInTheDocument();
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
// Only the first tab (Activity) should be active initially
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
||||
test("does not render when open is false", () => {
|
||||
render(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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);
|
||||
test("switches tabs correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// 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");
|
||||
// Initially shows activity tab (first tab is active)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
|
||||
// Check tabs structure
|
||||
expect(props.tabs).toHaveLength(2);
|
||||
expect(props.tabs[0].title).toBe("common.activity");
|
||||
expect(props.tabs[1].title).toBe("common.settings");
|
||||
// Click settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// 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);
|
||||
// Now shows settings tab content
|
||||
expect(screen.queryByTestId("action-activity-tab")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
||||
throw new Error("Tabs children are not defined");
|
||||
}
|
||||
// Click activity tab again
|
||||
const activityTab = screen.getByText("Activity");
|
||||
await user.click(activityTab);
|
||||
|
||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
||||
// Back to activity tab content
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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);
|
||||
test("resets to first tab when modal is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
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);
|
||||
// Switch to settings tab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
expect(screen.getByTestId("action-settings-tab")).toBeInTheDocument();
|
||||
|
||||
// Close modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={false} />);
|
||||
|
||||
// Reopen modal
|
||||
rerender(<ActionDetailModal {...defaultProps} open={true} />);
|
||||
|
||||
// Should be back to activity tab (first tab)
|
||||
expect(screen.getByTestId("action-activity-tab")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("action-settings-tab")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correct icon based on action type", () => {
|
||||
@@ -148,33 +191,68 @@ describe("ActionDetailModal", () => {
|
||||
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(screen.getByTestId("nocode-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("code-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
||||
expect(props.icon).toBeDefined();
|
||||
test("handles action without description", () => {
|
||||
const actionWithoutDescription = { ...mockActionClass, description: "" };
|
||||
render(<ActionDetailModal {...defaultProps} actionClass={actionWithoutDescription} />);
|
||||
|
||||
if (!props.icon) {
|
||||
throw new Error("Icon prop is not defined");
|
||||
}
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Test Action");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Code action");
|
||||
});
|
||||
|
||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
||||
test("passes correct props to ActionActivityTab", () => {
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
{
|
||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
||||
otherEnvironment: mockOtherEnvironment,
|
||||
isReadOnly: false,
|
||||
environment: mockEnvironment,
|
||||
actionClass: mockActionClass,
|
||||
environmentId: mockEnvironmentId,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes correct props to ActionSettingsTab when tab is active", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ActionDetailModal {...defaultProps} />);
|
||||
|
||||
// ActionSettingsTab should not be called initially since first tab is active
|
||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
||||
expect(mockedActionSettingsTab).not.toHaveBeenCalled();
|
||||
|
||||
// Click the settings tab to activate ActionSettingsTab
|
||||
const settingsTab = screen.getByText("Settings");
|
||||
await user.click(settingsTab);
|
||||
|
||||
// Now ActionSettingsTab should be called with correct props
|
||||
expect(mockedActionSettingsTab).toHaveBeenCalledWith(
|
||||
{
|
||||
actionClass: mockActionClass,
|
||||
actionClasses: mockActionClasses,
|
||||
setOpen: mockSetOpen,
|
||||
isReadOnly: false,
|
||||
},
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
test("passes isReadOnly prop correctly", () => {
|
||||
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);
|
||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
||||
expect(mockedActionActivityTab).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isReadOnly: true,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,16 +59,24 @@ export const ActionDetailModal = ({
|
||||
},
|
||||
];
|
||||
|
||||
const typeDescription = () => {
|
||||
if (actionClass.description) return actionClass.description;
|
||||
else
|
||||
return (
|
||||
(actionClass.type && actionClass.type === "noCode" ? t("common.no_code") : t("common.code")) +
|
||||
" " +
|
||||
t("common.action").toLowerCase()
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={actionClass.description || ""}
|
||||
/>
|
||||
</>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||
label={actionClass.name}
|
||||
description={typeDescription()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,22 +11,21 @@ export const ActionClassDataRow = ({
|
||||
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">
|
||||
<div className="m-2 grid grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-start py-3 pl-6 text-sm">
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="mt-1 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 className="text-left">
|
||||
<div className="break-words font-medium text-slate-900">{actionClass.name}</div>
|
||||
<div className="break-words 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,21 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
||||
updateActionClassAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action utils
|
||||
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
|
||||
useActionClassKeys: vi.fn(() => ["existing-key"]),
|
||||
createActionClassZodResolver: vi.fn(() => vi.fn()),
|
||||
validatePermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock action builder
|
||||
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
|
||||
buildActionObject: vi.fn((data, environmentId, t) => ({
|
||||
...data,
|
||||
environmentId,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock utils
|
||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||
@@ -24,6 +39,7 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -31,6 +47,7 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||
open ? (
|
||||
@@ -43,6 +60,26 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
|
||||
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
|
||||
<div data-testid="action-name-description-fields">
|
||||
<input
|
||||
data-testid={`name-input-${nameInputId}`}
|
||||
placeholder="environments.actions.eg_clicked_download"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Action"
|
||||
/>
|
||||
<input
|
||||
data-testid={`description-input-${descriptionInputId}`}
|
||||
placeholder="environments.actions.user_clicked_download_button"
|
||||
disabled={isReadOnly}
|
||||
defaultValue="Test Description"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||
@@ -56,6 +93,23 @@ vi.mock("lucide-react", () => ({
|
||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||
}));
|
||||
|
||||
// Mock react-hook-form
|
||||
const mockHandleSubmit = vi.fn();
|
||||
const mockForm = {
|
||||
handleSubmit: mockHandleSubmit,
|
||||
control: {},
|
||||
formState: { errors: {} },
|
||||
};
|
||||
|
||||
vi.mock("react-hook-form", async () => {
|
||||
const actual = await vi.importActual("react-hook-form");
|
||||
return {
|
||||
...actual,
|
||||
useForm: vi.fn(() => mockForm),
|
||||
FormProvider: ({ children }: any) => <div>{children}</div>,
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetOpen = vi.fn();
|
||||
const mockActionClasses: TActionClass[] = [
|
||||
{
|
||||
@@ -88,6 +142,7 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
|
||||
describe("ActionSettingsTab", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHandleSubmit.mockImplementation((fn) => fn);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -105,13 +160,9 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||
@@ -131,18 +182,104 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||
actionClass.name
|
||||
);
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||
actionClass.description
|
||||
);
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders correctly for other action types (fallback)", () => {
|
||||
const actionClass = {
|
||||
...createMockActionClass("auto1", "noCode", "Auto Action"),
|
||||
type: "automatic" as any,
|
||||
};
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls utility functions on initialization", async () => {
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles successful form submission", async () => {
|
||||
const { updateActionClassAction } = await import(
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
|
||||
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that utility functions were called during component initialization
|
||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles permission validation error", async () => {
|
||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
||||
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
|
||||
throw new Error("Not authorized");
|
||||
});
|
||||
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
|
||||
|
||||
mockHandleSubmit.mockImplementation((fn) => (e) => {
|
||||
e.preventDefault();
|
||||
return fn({ name: "Test", type: "noCode" });
|
||||
});
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Not authorized");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles successful deletion", async () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
const { deleteActionClassAction } = await import(
|
||||
@@ -209,17 +346,16 @@ describe("ActionSettingsTab", () => {
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={true} // Set to read-only
|
||||
isReadOnly={true}
|
||||
/>
|
||||
);
|
||||
|
||||
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
|
||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("prevents delete when read-only", async () => {
|
||||
@@ -228,7 +364,6 @@ describe("ActionSettingsTab", () => {
|
||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||
);
|
||||
|
||||
// Render with isReadOnly=true, but simulate a delete attempt
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
@@ -238,12 +373,6 @@ describe("ActionSettingsTab", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||
// A better approach might be to export handleDeleteAction for direct testing,
|
||||
// but for now, we assume the UI prevents calling it.
|
||||
|
||||
// We can assert that the delete button isn't there to prevent the flow
|
||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -262,4 +391,19 @@ describe("ActionSettingsTab", () => {
|
||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
test("uses correct input IDs for ActionNameDescriptionFields", () => {
|
||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||
render(
|
||||
<ActionSettingsTab
|
||||
actionClass={actionClass}
|
||||
actionClasses={mockActionClasses}
|
||||
setOpen={mockSetOpen}
|
||||
isReadOnly={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,14 +4,17 @@ import {
|
||||
deleteActionClassAction,
|
||||
updateActionClassAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
|
||||
import {
|
||||
createActionClassZodResolver,
|
||||
useActionClassKeys,
|
||||
validatePermissions,
|
||||
} from "@/modules/survey/editor/lib/action-utils";
|
||||
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
|
||||
import { 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";
|
||||
@@ -19,8 +22,7 @@ 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";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
actionClass: TActionClass;
|
||||
@@ -48,63 +50,51 @@ export const ActionSettingsTab = ({
|
||||
[actionClass.id, actionClasses]
|
||||
);
|
||||
|
||||
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
|
||||
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
})
|
||||
),
|
||||
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
|
||||
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const { handleSubmit, control } = form;
|
||||
|
||||
const renderActionForm = () => {
|
||||
if (actionClass.type === "code") {
|
||||
return (
|
||||
<>
|
||||
<CodeActionForm form={form} isReadOnly={true} />
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (actionClass.type === "noCode") {
|
||||
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="text-sm text-slate-600">
|
||||
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: TActionClassInput) => {
|
||||
try {
|
||||
if (isReadOnly) {
|
||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||
}
|
||||
setIsUpdatingAction(true);
|
||||
validatePermissions(isReadOnly, t);
|
||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
||||
|
||||
if (data.name && actionClassNames.includes(data.name)) {
|
||||
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
||||
}
|
||||
|
||||
if (
|
||||
data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" &&
|
||||
data.noCodeConfig.elementSelector.cssSelector &&
|
||||
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
||||
) {
|
||||
throw new Error(t("environments.actions.invalid_css_selector"));
|
||||
}
|
||||
|
||||
const updatedData: TActionClassInput = {
|
||||
...data,
|
||||
...(data.type === "noCode" &&
|
||||
data.noCodeConfig?.type === "click" && {
|
||||
noCodeConfig: {
|
||||
...data.noCodeConfig,
|
||||
elementSelector: {
|
||||
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
||||
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
await updateActionClassAction({
|
||||
actionClassId: actionClass.id,
|
||||
updatedAction: updatedData,
|
||||
updatedAction: updatedAction,
|
||||
});
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
@@ -123,7 +113,7 @@ export const ActionSettingsTab = ({
|
||||
router.refresh();
|
||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||
} finally {
|
||||
setIsDeletingAction(false);
|
||||
@@ -135,89 +125,23 @@ export const ActionSettingsTab = ({
|
||||
<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>
|
||||
<ActionNameDescriptionFields
|
||||
control={control}
|
||||
isReadOnly={isReadOnly}
|
||||
nameInputId="actionNameSettingsInput"
|
||||
descriptionInputId="actionDescriptionSettingsInput"
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
{renderActionForm()}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
className="mr-3"
|
||||
id="deleteActionModalTrigger">
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
|
||||
@@ -22,14 +22,29 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal" {...props}>
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children, className }: any) => (
|
||||
<h2 data-testid="dialog-title" className={className}>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="dialog-description">{children}</div>
|
||||
),
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -70,17 +85,21 @@ describe("AddActionModal", () => {
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
||||
test("opens the dialog 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("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-body")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -108,35 +127,35 @@ describe("AddActionModal", () => {
|
||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
||||
test("closes the dialog 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();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
|
||||
// Simulate closing via the mocked Modal's close button
|
||||
const closeModalButton = screen.getByText("Close Modal");
|
||||
await userEvent.click(closeModalButton);
|
||||
// Simulate closing via the mocked Dialog's close button
|
||||
const closeDialogButton = screen.getByText("Close Dialog");
|
||||
await userEvent.click(closeDialogButton);
|
||||
|
||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
||||
test("closes the dialog 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();
|
||||
expect(screen.getByTestId("dialog")).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();
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
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 {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
@@ -26,36 +33,26 @@ export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: Add
|
||||
{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>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,36 @@ 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>
|
||||
|
||||
@@ -51,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>,
|
||||
}));
|
||||
@@ -100,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;
|
||||
@@ -146,6 +138,7 @@ const defaultProps = {
|
||||
membershipRole: "owner" as const,
|
||||
organizationProjectsLimit: 5,
|
||||
isLicenseActive: true,
|
||||
isAccessControlAllowed: true,
|
||||
};
|
||||
|
||||
describe("MainNavigation", () => {
|
||||
@@ -166,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();
|
||||
@@ -183,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");
|
||||
});
|
||||
@@ -198,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");
|
||||
});
|
||||
@@ -221,7 +210,6 @@ describe("MainNavigation", () => {
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
|
||||
// Set up localStorage spy on the mocked localStorage
|
||||
const removeItemSpy = vi.spyOn(window.localStorage, "removeItem");
|
||||
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
@@ -235,71 +223,24 @@ 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);
|
||||
|
||||
// Verify localStorage.removeItem is called with the correct key
|
||||
expect(removeItemSpy).toHaveBeenCalledWith("formbricks-environment-id");
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
// Clean up spy
|
||||
removeItemSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles organization switching", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||
await userEvent.click(org2Item);
|
||||
|
||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||
});
|
||||
|
||||
test("opens create organization modal", async () => {
|
||||
render(<MainNavigation {...defaultProps} />);
|
||||
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
|
||||
// Wait for the initial dropdown items
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||
|
||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||
await userEvent.click(createOrgButton);
|
||||
|
||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides new version banner for members or if no new version", async () => {
|
||||
@@ -329,15 +270,25 @@ describe("MainNavigation", () => {
|
||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows billing link and hides license link in cloud", async () => {
|
||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||
await userEvent.click(userTrigger);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,27 +2,17 @@
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
@@ -31,18 +21,14 @@ import {
|
||||
BlocksIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
CreditCardIcon,
|
||||
KeyIcon,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
RocketIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -51,53 +37,40 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import packageJson from "../../../../../package.json";
|
||||
|
||||
interface NavigationProps {
|
||||
environment: TEnvironment;
|
||||
organizations: TOrganization[];
|
||||
user: TUser;
|
||||
organization: TOrganization;
|
||||
projects: TProject[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
projects: { id: string; name: string }[];
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
organizationProjectsLimit: number;
|
||||
isLicenseActive: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -118,40 +91,11 @@ export const MainNavigation = ({
|
||||
}, [isCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organization && organization.name !== "") {
|
||||
setCurrentOrganizationName(organization.name);
|
||||
setCurrentOrganizationId(organization.id);
|
||||
// Auto collapse project navbar on org and account settings
|
||||
if (pathname?.includes("/settings")) {
|
||||
setIsCollapsed(true);
|
||||
}
|
||||
}, [organization]);
|
||||
|
||||
const sortedOrganizations = useMemo(() => {
|
||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [organizations]);
|
||||
|
||||
const sortedProjects = useMemo(() => {
|
||||
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
||||
|
||||
const groupedProjects = projects.reduce(
|
||||
(acc, project) => {
|
||||
const channel = project.config.channel;
|
||||
const key = channel !== null ? channel : "null";
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(project);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof projects>
|
||||
);
|
||||
|
||||
Object.keys(groupedProjects).forEach((channel) => {
|
||||
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
|
||||
});
|
||||
|
||||
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
|
||||
}, [projects]);
|
||||
|
||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
() => [
|
||||
@@ -196,29 +140,18 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/settings/profile`,
|
||||
icon: UserCircleIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.organization"),
|
||||
href: `/environments/${environment.id}/settings/general`,
|
||||
icon: UsersIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${environment.id}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.license"),
|
||||
href: `/environments/${environment.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
icon: KeyIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.documentation"),
|
||||
href: "https://formbricks.com/docs",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
{
|
||||
label: t("common.share_feedback"),
|
||||
href: "https://github.com/formbricks/formbricks/issues",
|
||||
target: "_blank",
|
||||
icon: ArrowUpRightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
@@ -228,7 +161,7 @@ export const MainNavigation = ({
|
||||
const latestVersionTag = res.data;
|
||||
const currentVersionTag = `v${packageJson.version}`;
|
||||
|
||||
if (currentVersionTag !== latestVersionTag) {
|
||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||
setLatestVersion(latestVersionTag);
|
||||
}
|
||||
}
|
||||
@@ -244,8 +177,7 @@ export const MainNavigation = ({
|
||||
<aside
|
||||
className={cn(
|
||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
||||
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
@@ -311,22 +243,6 @@ export const MainNavigation = ({
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Project Switch */}
|
||||
{!isBilling && (
|
||||
<ProjectSwitcher
|
||||
environmentId={environment.id}
|
||||
projects={sortedProjects}
|
||||
project={project}
|
||||
isCollapsed={isCollapsed}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isTextVisible={isTextVisible}
|
||||
organization={organization}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* User Switch */}
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
@@ -335,29 +251,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>
|
||||
@@ -371,89 +285,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 () => {
|
||||
localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
|
||||
|
||||
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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,32 +1,110 @@
|
||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||
"use client";
|
||||
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
interface SideBarProps {
|
||||
environment: TEnvironment;
|
||||
interface TopControlBarProps {
|
||||
environments: TEnvironment[];
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({
|
||||
environment,
|
||||
environments,
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: SideBarProps) => {
|
||||
}: TopControlBarProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="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
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<div className="flex items-center">
|
||||
<ProjectAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/issues"
|
||||
target="_blank"
|
||||
aria-label={t("common.share_feedback")}
|
||||
rel="noopener noreferrer">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
|
||||
<CircleUserIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/templates`}
|
||||
aria-label={t("common.new_survey")}>
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
</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,349 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
TooltipTrigger: ({ children, asChild }: any) => (
|
||||
<div data-testid="tooltip-trigger" data-as-child={asChild}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
TooltipContent: ({ children, className }: any) => (
|
||||
<div data-testid="tooltip-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
Code2Icon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>Code2 Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
CircleHelpIcon: ({ className }: any) => (
|
||||
<svg data-testid="circle-help-icon" className={className}>
|
||||
<title>CircleHelp Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("EnvironmentBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProductionEnvironment: TEnvironment = {
|
||||
id: "env-prod-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "production",
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockDevelopmentEnvironment: TEnvironment = {
|
||||
id: "env-dev-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "development",
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("renders environment breadcrumb with production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("renders environment breadcrumb with development environment and shows tooltip", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
|
||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("highlights breadcrumb item for development environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
|
||||
});
|
||||
|
||||
test("does not highlight breadcrumb item for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
|
||||
});
|
||||
|
||||
test("shows chevron down icon when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
test("renders dropdown content with environment options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all environment options in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
expect(checkboxItems).toHaveLength(2);
|
||||
|
||||
// Check production environment option
|
||||
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
|
||||
expect(productionOption).toBeInTheDocument();
|
||||
expect(productionOption).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check development environment option
|
||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||
expect(developmentOption).toBeInTheDocument();
|
||||
expect(developmentOption).toHaveAttribute("data-checked", "false");
|
||||
});
|
||||
|
||||
test("handles environment change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||
|
||||
expect(developmentOption).toBeInTheDocument();
|
||||
await user.click(developmentOption!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
|
||||
});
|
||||
|
||||
test("capitalizes environment type in display", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const environmentSpans = screen.getAllByText("production");
|
||||
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
|
||||
expect(triggerSpan).toHaveClass("capitalize");
|
||||
});
|
||||
|
||||
test("tooltip shows correct content for development environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const tooltipContent = screen.getByTestId("tooltip-content");
|
||||
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
|
||||
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
|
||||
});
|
||||
|
||||
test("renders without tooltip for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("handles single environment scenario", () => {
|
||||
const singleEnvironment = [mockProductionEnvironment];
|
||||
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={singleEnvironment}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export const EnvironmentBreadcrumb = ({
|
||||
environments,
|
||||
currentEnvironmentId,
|
||||
}: {
|
||||
environments: { id: string; type: string }[];
|
||||
currentEnvironmentId: string;
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEnvironmentChange = (environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/environments/${environmentId}/`);
|
||||
};
|
||||
|
||||
const developmentTooltip = () => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelpIcon className="h-3 w-3" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
||||
{t("common.development_environment_banner")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<BreadcrumbItem
|
||||
isActive={isEnvironmentDropdownOpen}
|
||||
isHighlighted={currentEnvironment.type === "development"}>
|
||||
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="environmentDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span className="capitalize">{currentEnvironment.type}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{currentEnvironment.type === "development" && developmentTooltip()}
|
||||
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="mt-2" align="start">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<Code2Icon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_environment")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={env.id}
|
||||
checked={env.id === currentEnvironment.id}
|
||||
onClick={() => handleEnvironmentChange(env.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
<span>{env.type}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -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,173 @@
|
||||
"use client";
|
||||
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
BuildingIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
Loader2,
|
||||
PlusIcon,
|
||||
SettingsIcon,
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
currentEnvironmentId?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
isMember: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
}
|
||||
|
||||
export const OrganizationBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
isFormbricksCloud,
|
||||
isMember,
|
||||
isOwnerOrManager,
|
||||
}: OrganizationBreadcrumbProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
|
||||
if (!currentOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
// Hide organization dropdown for single org setups (on-premise)
|
||||
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
||||
|
||||
const organizationSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/general`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
hidden: !isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
label: t("common.enterprise_license"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="organizationDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentOrganization.name}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isOrganizationDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
{showOrganizationDropdown && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_organization")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
checked={org.id === currentOrganization.id}
|
||||
onClick={() => handleOrganizationChange(org.id)}
|
||||
className="cursor-pointer">
|
||||
{org.name}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isMultiOrgEnabled && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||
className="cursor-pointer">
|
||||
<span>{t("common.create_new_organization")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.organization_settings")}
|
||||
</div>
|
||||
|
||||
{organizationSettings.map((setting) => {
|
||||
return setting.hidden ? null : (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={pathname.includes(setting.id)}
|
||||
hidden={setting.hidden}
|
||||
onClick={() => router.push(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{openCreateOrganizationModal && (
|
||||
<CreateOrganizationModal
|
||||
open={openCreateOrganizationModal}
|
||||
setOpen={setOpenCreateOrganizationModal}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { ProjectAndOrgSwitch } from "./project-and-org-switch";
|
||||
|
||||
// Mock the individual breadcrumb components
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/organization-breadcrumb", () => ({
|
||||
OrganizationBreadcrumb: ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
isMultiOrgEnabled,
|
||||
currentEnvironmentId,
|
||||
}: any) => {
|
||||
const currentOrganization = organizations.find((org: any) => org.id === currentOrganizationId);
|
||||
return (
|
||||
<div data-testid="organization-breadcrumb">
|
||||
<div>Organization: {currentOrganization?.name}</div>
|
||||
<div>Organizations Count: {organizations.length}</div>
|
||||
<div>Multi Org: {isMultiOrgEnabled ? "Enabled" : "Disabled"}</div>
|
||||
<div>Environment ID: {currentEnvironmentId}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-breadcrumb", () => ({
|
||||
ProjectBreadcrumb: ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
}: any) => {
|
||||
const currentProject = projects.find((project: any) => project.id === currentProjectId);
|
||||
return (
|
||||
<div data-testid="project-breadcrumb">
|
||||
<div>Project: {currentProject?.name}</div>
|
||||
<div>Projects Count: {projects.length}</div>
|
||||
<div>Owner/Manager: {isOwnerOrManager ? "Yes" : "No"}</div>
|
||||
<div>Project Limit: {organizationProjectsLimit}</div>
|
||||
<div>Formbricks Cloud: {isFormbricksCloud ? "Yes" : "No"}</div>
|
||||
<div>License Active: {isLicenseActive ? "Yes" : "No"}</div>
|
||||
<div>Organization ID: {currentOrganizationId}</div>
|
||||
<div>Environment ID: {currentEnvironmentId}</div>
|
||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/components/environment-breadcrumb", () => ({
|
||||
EnvironmentBreadcrumb: ({ environments, currentEnvironmentId }: any) => {
|
||||
const currentEnvironment = environments.find((env: any) => env.id === currentEnvironmentId);
|
||||
return (
|
||||
<div data-testid="environment-breadcrumb">
|
||||
<div>Environment: {currentEnvironment?.type}</div>
|
||||
<div>Environments Count: {environments.length}</div>
|
||||
<div>Environment ID: {currentEnvironment?.id}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
Breadcrumb: ({ children }: any) => (
|
||||
<nav data-testid="breadcrumb" aria-label="breadcrumb">
|
||||
{children}
|
||||
</nav>
|
||||
),
|
||||
BreadcrumbList: ({ children, className }: any) => (
|
||||
<ol data-testid="breadcrumb-list" className={className}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProjectAndOrgSwitch", () => {
|
||||
const mockOrganization1 = {
|
||||
id: "org-1",
|
||||
name: "Test Organization 1",
|
||||
};
|
||||
|
||||
const mockOrganization2 = {
|
||||
id: "org-2",
|
||||
name: "Test Organization 2",
|
||||
};
|
||||
|
||||
const mockProject1 = {
|
||||
id: "proj-1",
|
||||
name: "Test Project 1",
|
||||
};
|
||||
|
||||
const mockProject2 = {
|
||||
id: "proj-2",
|
||||
name: "Test Project 2",
|
||||
};
|
||||
|
||||
const mockEnvironment1: TEnvironment = {
|
||||
id: "env-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "production",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const mockEnvironment2: TEnvironment = {
|
||||
id: "env-2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "development",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
currentOrganizationId: "org-1",
|
||||
organizations: [mockOrganization1, mockOrganization2],
|
||||
currentProjectId: "proj-1",
|
||||
projects: [mockProject1, mockProject2],
|
||||
currentEnvironmentId: "env-1",
|
||||
environments: [mockEnvironment1, mockEnvironment2],
|
||||
isMultiOrgEnabled: true,
|
||||
organizationProjectsLimit: 5,
|
||||
isFormbricksCloud: true,
|
||||
isLicenseActive: false,
|
||||
isOwnerOrManager: true,
|
||||
isAccessControlAllowed: true,
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders main breadcrumb structure", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("breadcrumb-list")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("aria-label", "breadcrumb");
|
||||
});
|
||||
|
||||
test("applies correct CSS classes to breadcrumb list", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
||||
expect(breadcrumbList).toHaveClass("gap-0");
|
||||
});
|
||||
|
||||
test("renders all three breadcrumb components", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Organization Breadcrumb Integration", () => {
|
||||
test("passes correct props to organization breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 1");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 2");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Enabled");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
});
|
||||
|
||||
test("handles single organization setup", () => {
|
||||
render(
|
||||
<ProjectAndOrgSwitch
|
||||
{...defaultProps}
|
||||
organizations={[mockOrganization1]}
|
||||
isMultiOrgEnabled={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 1");
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Breadcrumb Integration", () => {
|
||||
test("passes correct props to project breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project: Test Project 1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Projects Count: 2");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: Yes");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 5");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: Yes");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Allowed");
|
||||
});
|
||||
|
||||
test("handles non-owner/manager user", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isOwnerOrManager={false} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
||||
});
|
||||
|
||||
test("handles self-hosted setup", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isFormbricksCloud={false} isLicenseActive={true} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: Yes");
|
||||
});
|
||||
|
||||
test("handles access control restrictions", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} isAccessControlAllowed={false} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Environment Breadcrumb Integration", () => {
|
||||
test("passes correct props to environment breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: production");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
});
|
||||
|
||||
test("handles development environment", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: development");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
|
||||
});
|
||||
|
||||
test("handles single environment", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} environments={[mockEnvironment1]} />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props Propagation", () => {
|
||||
test("correctly propagates organization limits", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={10} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 10");
|
||||
});
|
||||
|
||||
test("correctly propagates current organization to project breadcrumb", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} currentOrganizationId="org-2" />);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
|
||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 2");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles zero project limit", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={0} />);
|
||||
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 0");
|
||||
});
|
||||
|
||||
test("handles all boolean props as false", () => {
|
||||
render(
|
||||
<ProjectAndOrgSwitch
|
||||
{...defaultProps}
|
||||
isMultiOrgEnabled={false}
|
||||
isFormbricksCloud={false}
|
||||
isLicenseActive={false}
|
||||
isOwnerOrManager={false}
|
||||
isAccessControlAllowed={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
||||
|
||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
||||
});
|
||||
|
||||
test("maintains component order in DOM", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
||||
const children = Array.from(breadcrumbList.children);
|
||||
|
||||
expect(children[0]).toHaveAttribute("data-testid", "organization-breadcrumb");
|
||||
expect(children[1]).toHaveAttribute("data-testid", "project-breadcrumb");
|
||||
expect(children[2]).toHaveAttribute("data-testid", "environment-breadcrumb");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TypeScript Props Interface", () => {
|
||||
test("accepts all required props without error", () => {
|
||||
// This test ensures the component accepts the full interface
|
||||
expect(() => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
test("works with minimal valid props", () => {
|
||||
const minimalProps = {
|
||||
currentOrganizationId: "org-1",
|
||||
organizations: [mockOrganization1],
|
||||
currentProjectId: "proj-1",
|
||||
projects: [mockProject1],
|
||||
currentEnvironmentId: "env-1",
|
||||
environments: [mockEnvironment1],
|
||||
isMultiOrgEnabled: false,
|
||||
organizationProjectsLimit: 1,
|
||||
isFormbricksCloud: false,
|
||||
isLicenseActive: false,
|
||||
isOwnerOrManager: false,
|
||||
isAccessControlAllowed: false,
|
||||
isMember: true,
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<ProjectAndOrgSwitch {...minimalProps} />);
|
||||
}).not.toThrow();
|
||||
|
||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
||||
import { useMemo } from "react";
|
||||
|
||||
interface ProjectAndOrgSwitchProps {
|
||||
currentOrganizationId: string;
|
||||
organizations: { id: string; name: string }[];
|
||||
currentProjectId?: string;
|
||||
projects: { id: string; name: string }[];
|
||||
currentEnvironmentId?: string;
|
||||
environments: { id: string; type: string }[];
|
||||
isMultiOrgEnabled: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
isMember: boolean;
|
||||
}
|
||||
|
||||
export const ProjectAndOrgSwitch = ({
|
||||
currentOrganizationId,
|
||||
organizations,
|
||||
currentProjectId,
|
||||
projects,
|
||||
currentEnvironmentId,
|
||||
environments,
|
||||
isMultiOrgEnabled,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
isMember,
|
||||
}: ProjectAndOrgSwitchProps) => {
|
||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
||||
const sortedOrganizations = useMemo(
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList className="gap-0">
|
||||
<OrganizationBreadcrumb
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={sortedOrganizations}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isMember={isMember}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
/>
|
||||
{currentProjectId && currentEnvironmentId && (
|
||||
<ProjectBreadcrumb
|
||||
currentProjectId={currentProjectId}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
currentEnvironmentId={currentEnvironmentId}
|
||||
projects={sortedProjects}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,497 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { ProjectBreadcrumb } from "./project-breadcrumb";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
|
||||
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
|
||||
open ? (
|
||||
<div data-testid="project-limit-modal">
|
||||
<div>Project Limit: {projectLimit}</div>
|
||||
<button onClick={() => setOpen(false)}>Close Limit Modal</button>
|
||||
{buttons.map((button: any) => (
|
||||
<button key={button.text} type="button" onClick={() => button.href && window.open(button.href)}>
|
||||
{button.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/projects/components/create-project-modal", () => ({
|
||||
CreateProjectModal: ({ open, setOpen, organizationId, isAccessControlAllowed }: any) =>
|
||||
open ? (
|
||||
<div data-testid="create-project-modal">
|
||||
<div>Organization: {organizationId}</div>
|
||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
||||
<button onClick={() => setOpen(false)}>Close Create Modal</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="dropdown-menu"
|
||||
onClick={() => onOpenChange?.(true)}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dropdown-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||
<div
|
||||
data-testid="dropdown-checkbox-item"
|
||||
data-checked={checked}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
tabIndex={0}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||
<button data-testid="dropdown-trigger" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
FolderOpenIcon: ({ className, strokeWidth }: any) => {
|
||||
const isHeader = className?.includes("mr-2");
|
||||
return (
|
||||
<svg
|
||||
data-testid={isHeader ? "folder-open-header-icon" : "folder-open-icon"}
|
||||
className={className}
|
||||
strokeWidth={strokeWidth}>
|
||||
<title>FolderOpen Icon</title>
|
||||
</svg>
|
||||
);
|
||||
},
|
||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronDown Icon</title>
|
||||
</svg>
|
||||
),
|
||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
||||
<title>ChevronRight Icon</title>
|
||||
</svg>
|
||||
),
|
||||
PlusIcon: ({ className }: any) => (
|
||||
<svg data-testid="plus-icon" className={className}>
|
||||
<title>Plus Icon</title>
|
||||
</svg>
|
||||
),
|
||||
Loader2: ({ className }: any) => (
|
||||
<svg data-testid="loader-2-icon" className={className}>
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProjectBreadcrumb", () => {
|
||||
const mockPush = vi.fn();
|
||||
const mockRouter = {
|
||||
push: mockPush,
|
||||
replace: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockProject1 = {
|
||||
id: "proj-1",
|
||||
name: "Test Project 1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
organizationId: "org-1",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockProject2 = {
|
||||
id: "proj-2",
|
||||
name: "Test Project 2",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
organizationId: "org-1",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const mockProjects = [mockProject1, mockProject2];
|
||||
|
||||
const mockOrganization: TOrganization = {
|
||||
id: "org-1",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
billing: {
|
||||
plan: "free",
|
||||
stripeCustomerId: null,
|
||||
} as unknown as TOrganizationBilling,
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
currentProjectId: "proj-1",
|
||||
currentOrganizationId: "org-1",
|
||||
projects: mockProjects,
|
||||
isOwnerOrManager: true,
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: true,
|
||||
isLicenseActive: false,
|
||||
currentEnvironmentId: "env-123",
|
||||
isAccessControlAllowed: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Basic Rendering", () => {
|
||||
test("renders project breadcrumb correctly", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("folder-open-icon")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("shows chevron icons correctly", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
// Should show chevron right when closed
|
||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows chevron down when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Selection", () => {
|
||||
test("renders dropdown content with project options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders all project options in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
|
||||
// Find project options (excluding the add new project option)
|
||||
const projectOptions = checkboxItems.filter((item) => item.textContent?.includes("Test Project"));
|
||||
expect(projectOptions).toHaveLength(2);
|
||||
|
||||
// Check current project is marked as selected
|
||||
const currentProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 1"));
|
||||
expect(currentProjectOption).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check other project is not selected
|
||||
const otherProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
||||
expect(otherProjectOption).toHaveAttribute("data-checked", "false");
|
||||
});
|
||||
|
||||
test("handles project change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||
const project2Option = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
||||
|
||||
expect(project2Option).toBeInTheDocument();
|
||||
await user.click(project2Option!);
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/projects/proj-2/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Add New Project", () => {
|
||||
test("shows add new project option when user is owner or manager", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.getByText("common.add_new_project")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides add new project option when user is not owner or manager", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} isOwnerOrManager={false} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
expect(screen.queryByText("common.add_new_project")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens create project modal when within project limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Organization: org-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Access Control: Allowed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens limit modal when exceeding project limit", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Limit: 3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Project Limit Modal", () => {
|
||||
test("shows correct buttons for Formbricks Cloud with non-enterprise plan", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: true,
|
||||
currentOrganization: {
|
||||
...mockOrganization,
|
||||
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
|
||||
},
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows correct buttons for self-hosted with active license", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: false,
|
||||
isLicenseActive: true,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("closes limit modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Limit Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Create Project Modal", () => {
|
||||
test("closes create project modal correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
||||
|
||||
const closeButton = screen.getByText("Close Create Modal");
|
||||
await user.click(closeButton);
|
||||
|
||||
expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("passes correct props to create project modal", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} isAccessControlAllowed={false} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
expect(screen.getByText("Access Control: Not Allowed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
test("handles single project scenario", () => {
|
||||
render(<ProjectBreadcrumb {...defaultProps} projects={[mockProject1]} />);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
||||
});
|
||||
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
||||
|
||||
// Initially not active
|
||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||
|
||||
// Open dropdown
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
// Should be active when dropdown is open
|
||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
test("handles project limit of zero", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectBreadcrumb {...defaultProps} organizationProjectsLimit={0} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
// Should show limit modal even with 0 projects when limit is 0
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
expect(screen.getByText("Project Limit: 0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles enterprise plan on Formbricks Cloud", async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
currentOrganization: {
|
||||
...mockOrganization,
|
||||
billing: { ...mockOrganization.billing, plan: "enterprise" } as unknown as TOrganizationBilling,
|
||||
},
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
await user.click(dropdownMenu);
|
||||
|
||||
const addProjectOption = screen.getByText("common.add_new_project");
|
||||
await user.click(addProjectOption);
|
||||
|
||||
// Should show self-hosted style buttons for enterprise plan
|
||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
projects: { id: string; name: string }[];
|
||||
isOwnerOrManager: boolean;
|
||||
organizationProjectsLimit: number;
|
||||
isFormbricksCloud: boolean;
|
||||
isLicenseActive: boolean;
|
||||
currentOrganizationId: string;
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
}
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
currentProjectId,
|
||||
projects,
|
||||
isOwnerOrManager,
|
||||
organizationProjectsLimit,
|
||||
isFormbricksCloud,
|
||||
isLicenseActive,
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
setIsLoading(true);
|
||||
router.push(`/projects/${projectId}/`);
|
||||
};
|
||||
|
||||
const handleAddProject = () => {
|
||||
if (projects.length >= organizationProjectsLimit) {
|
||||
setOpenLimitModal(true);
|
||||
return;
|
||||
}
|
||||
setOpenCreateProjectModal(true);
|
||||
};
|
||||
|
||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||
if (isFormbricksCloud) {
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: t("environments.settings.billing.upgrade"),
|
||||
href: isLicenseActive
|
||||
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||
},
|
||||
{
|
||||
text: t("common.cancel"),
|
||||
onClick: () => setOpenLimitModal(false),
|
||||
},
|
||||
];
|
||||
};
|
||||
return (
|
||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
||||
<DropdownMenuTrigger
|
||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
||||
id="projectDropdownTrigger"
|
||||
asChild>
|
||||
<div className="flex items-center gap-1">
|
||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
<span>{currentProject.name}</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
checked={proj.id === currentProject.id}
|
||||
onClick={() => handleProjectChange(proj.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{proj.name}</span>
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
{isOwnerOrManager && (
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
{openLimitModal && (
|
||||
<ProjectLimitModal
|
||||
open={openLimitModal}
|
||||
setOpen={setOpenLimitModal}
|
||||
buttons={LimitModalButtons()}
|
||||
projectLimit={organizationProjectsLimit}
|
||||
/>
|
||||
)}
|
||||
{openCreateProjectModal && (
|
||||
<CreateProjectModal
|
||||
open={openCreateProjectModal}
|
||||
setOpen={setOpenCreateProjectModal}
|
||||
organizationId={currentOrganizationId}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { EnvironmentContextWrapper, useEnvironment } from "./environment-context";
|
||||
|
||||
// Mock environment data
|
||||
const mockEnvironment: TEnvironment = {
|
||||
id: "test-env-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development",
|
||||
projectId: "test-project-id",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
|
||||
// Mock project data
|
||||
const mockProject = {
|
||||
id: "test-project-id",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "test-org-id",
|
||||
config: {
|
||||
channel: "app",
|
||||
industry: "saas",
|
||||
},
|
||||
linkSurveyBranding: true,
|
||||
styling: {
|
||||
allowStyleOverwrite: true,
|
||||
brandColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
questionColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputColor: {
|
||||
light: "#000000",
|
||||
dark: "#ffffff",
|
||||
},
|
||||
inputBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
cardBackgroundColor: {
|
||||
light: "#ffffff",
|
||||
dark: "#000000",
|
||||
},
|
||||
cardBorderColor: {
|
||||
light: "#cccccc",
|
||||
dark: "#444444",
|
||||
},
|
||||
isDarkModeEnabled: false,
|
||||
isLogoHidden: false,
|
||||
hideProgressBar: false,
|
||||
roundness: 8,
|
||||
cardArrangement: {
|
||||
linkSurveys: "casual",
|
||||
appSurveys: "casual",
|
||||
},
|
||||
},
|
||||
recontactDays: 30,
|
||||
inAppSurveyBranding: true,
|
||||
logo: {
|
||||
url: "test-logo.png",
|
||||
bgColor: "#ffffff",
|
||||
},
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
} as TProject;
|
||||
|
||||
// Test component that uses the hook
|
||||
const TestComponent = () => {
|
||||
const { environment, project } = useEnvironment();
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="environment-id">{environment.id}</div>
|
||||
<div data-testid="environment-type">{environment.type}</div>
|
||||
<div data-testid="project-id">{project.id}</div>
|
||||
<div data-testid="project-organization-id">{project.organizationId}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe("EnvironmentContext", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("provides environment and project data to child components", () => {
|
||||
render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
expect(screen.getByTestId("project-organization-id")).toHaveTextContent("test-org-id");
|
||||
});
|
||||
|
||||
test("throws error when useEnvironment is used outside of provider", () => {
|
||||
const TestComponentWithoutProvider = () => {
|
||||
useEnvironment();
|
||||
return <div>Should not render</div>;
|
||||
};
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponentWithoutProvider />);
|
||||
}).toThrow("useEnvironment must be used within an EnvironmentProvider");
|
||||
});
|
||||
|
||||
test("updates context value when environment or project changes", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("development");
|
||||
|
||||
const updatedEnvironment = {
|
||||
...mockEnvironment,
|
||||
type: "production" as const,
|
||||
};
|
||||
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={updatedEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("environment-type")).toHaveTextContent("production");
|
||||
});
|
||||
|
||||
test("memoizes context value correctly", () => {
|
||||
const { rerender } = render(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Re-render with same props
|
||||
rerender(
|
||||
<EnvironmentContextWrapper environment={mockEnvironment} project={mockProject}>
|
||||
<TestComponent />
|
||||
</EnvironmentContextWrapper>
|
||||
);
|
||||
|
||||
// Should still work correctly
|
||||
expect(screen.getByTestId("environment-id")).toHaveTextContent("test-env-id");
|
||||
expect(screen.getByTestId("project-id")).toHaveTextContent("test-project-id");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
|
||||
export interface EnvironmentContextType {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
|
||||
|
||||
export const useEnvironment = () => {
|
||||
const context = useContext(EnvironmentContext);
|
||||
if (!context) {
|
||||
throw new Error("useEnvironment must be used within an EnvironmentProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Client wrapper component to be used in server components
|
||||
interface EnvironmentContextWrapperProps {
|
||||
environment: TEnvironment;
|
||||
project: TProject;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const EnvironmentContextWrapper = ({
|
||||
environment,
|
||||
project,
|
||||
children,
|
||||
}: EnvironmentContextWrapperProps) => {
|
||||
const environmentContextValue = useMemo(
|
||||
() => ({
|
||||
environment,
|
||||
project,
|
||||
organizationId: project.organizationId,
|
||||
}),
|
||||
[environment, project]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -92,14 +92,24 @@ vi.mock("@/modules/ui/components/additional-integration-settings", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open, setOpen }) =>
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="modal">
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/alert", () => ({
|
||||
Alert: ({ children }) => <div data-testid="alert">{children}</div>,
|
||||
|
||||
@@ -10,8 +10,16 @@ import { AdditionalIntegrationSettings } from "@/modules/ui/components/additiona
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -19,11 +27,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
@@ -68,6 +76,80 @@ const NoBaseFoundError = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderQuestionSelection = ({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
}: {
|
||||
t: TFnType;
|
||||
selectedSurvey: TSurvey;
|
||||
control: Control<IntegrationModalInputs>;
|
||||
includeVariables: boolean;
|
||||
setIncludeVariables: (value: boolean) => void;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
setIncludeHiddenFields: (value: boolean) => void;
|
||||
setIncludeMetadata: (value: boolean) => void;
|
||||
includeCreatedAt: boolean;
|
||||
setIncludeCreatedAt: (value: boolean) => void;
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddIntegrationModal = ({
|
||||
open,
|
||||
setOpenWithStates,
|
||||
@@ -210,182 +292,148 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={handleClose} noPadding>
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent className="overflow-visible md:overflow-visible">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Image className="w-12" src={AirtableLogo} alt="Airtable logo" />
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={AirtableLogo}
|
||||
alt={t("environments.integrations.airtable.airtable_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.airtable.link_airtable_table")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.airtable.sync_responses_with_airtable")}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitHandler)}>
|
||||
<div className="flex rounded-lg p-6">
|
||||
<div className="flex w-full flex-col gap-y-4 pt-5">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(submitHandler)}>
|
||||
<DialogBody className="overflow-visible">
|
||||
<div className="flex w-full flex-col gap-y-4">
|
||||
{airtableArray.length ? (
|
||||
<BaseSelectDropdown
|
||||
control={control}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
}}
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
isLoading={isLoading}
|
||||
fetchTable={fetchTable}
|
||||
airtableArray={airtableArray}
|
||||
setValue={setValue}
|
||||
defaultValue={defaultData?.base}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<NoBaseFoundError />
|
||||
)}
|
||||
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
disabled={!tables.length}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
defaultValue={defaultData?.table}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
{tables.length ? (
|
||||
<SelectContent>
|
||||
{tables.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
) : null}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!surveys.length ? (
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{survey && selectedSurvey && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||
<Controller
|
||||
key={question.id}
|
||||
control={control}
|
||||
name={"questions"}
|
||||
render={({ field }) => (
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={question.id} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={question.id}
|
||||
value={question.id}
|
||||
className="bg-white"
|
||||
checked={field.value?.includes(question.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
return checked
|
||||
? field.onChange([...field.value, question.id])
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{surveys.length ? (
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="survey">{t("common.select_survey")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Controller
|
||||
control={control}
|
||||
name="survey"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
required
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val);
|
||||
setValue("questions", []);
|
||||
}}
|
||||
defaultValue={defaultData?.survey}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AdditionalIntegrationSettings
|
||||
includeVariables={includeVariables}
|
||||
setIncludeVariables={setIncludeVariables}
|
||||
includeHiddenFields={includeHiddenFields}
|
||||
includeMetadata={includeMetadata}
|
||||
setIncludeHiddenFields={setIncludeHiddenFields}
|
||||
setIncludeMetadata={setIncludeMetadata}
|
||||
includeCreatedAt={includeCreatedAt}
|
||||
setIncludeCreatedAt={setIncludeCreatedAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-x-2">
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<p className="m-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.create_survey_warning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
{survey &&
|
||||
selectedSurvey &&
|
||||
renderQuestionSelection({
|
||||
t,
|
||||
selectedSurvey,
|
||||
control,
|
||||
includeVariables,
|
||||
setIncludeVariables,
|
||||
includeHiddenFields,
|
||||
includeMetadata,
|
||||
setIncludeHiddenFields,
|
||||
setIncludeMetadata,
|
||||
includeCreatedAt,
|
||||
setIncludeCreatedAt,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{isEditMode ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await handleDelete(defaultData.index);
|
||||
}}
|
||||
type="button"
|
||||
loading={isLoading}
|
||||
variant="destructive">
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" loading={isLoading} variant="ghost" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button type="submit">{t("common.save")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,16 +30,16 @@ interface ManageIntegrationProps {
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
const tableHeaders = [
|
||||
"common.survey",
|
||||
"environments.integrations.airtable.table_name",
|
||||
"common.questions",
|
||||
"common.updated_at",
|
||||
];
|
||||
|
||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
const { airtableIntegration, environment, environmentId, setIsConnected, surveys, airtableArray } = props;
|
||||
const { t } = useTranslate();
|
||||
|
||||
const tableHeaders = [
|
||||
t("common.survey"),
|
||||
t("environments.integrations.airtable.table_name"),
|
||||
t("common.questions"),
|
||||
t("common.updated_at"),
|
||||
];
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [defaultValues, setDefaultValues] = useState<(IntegrationModalInputs & { index: number }) | null>(
|
||||
@@ -100,7 +100,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
{header}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -88,9 +88,24 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
open ? (
|
||||
<div data-testid="dialog" role="dialog">
|
||||
{children}
|
||||
<button onClick={() => onOpenChange(false)}>Close Dialog</button>
|
||||
</div>
|
||||
) : null,
|
||||
DialogContent: ({ children, ...props }: any) => (
|
||||
<div data-testid="dialog-content" {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children }: any) => <div data-testid="dialog-header">{children}</div>,
|
||||
DialogTitle: ({ children }: any) => <h2 data-testid="dialog-title">{children}</h2>,
|
||||
DialogDescription: ({ children }: any) => <p data-testid="dialog-description">{children}</p>,
|
||||
DialogBody: ({ children }: any) => <div data-testid="dialog-body">{children}</div>,
|
||||
DialogFooter: ({ children }: any) => <div data-testid="dialog-footer">{children}</div>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -205,7 +220,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -243,7 +257,6 @@ const surveys: TSurvey[] = [
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
hiddenFields: { enabled: true, fieldIds: [] },
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
@@ -304,10 +317,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
@@ -332,10 +344,9 @@ describe("AddIntegrationModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Link Google Sheet", { selector: "div.text-xl.font-medium" })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog-title")).toHaveTextContent("Link Google Sheet");
|
||||
expect(screen.getByTestId("dialog-description")).toHaveTextContent("Sync responses with Google Sheets.");
|
||||
// Use getByPlaceholderText for the input
|
||||
expect(
|
||||
screen.getByPlaceholderText("https://docs.google.com/spreadsheets/d/<your-spreadsheet-id>")
|
||||
|
||||
@@ -14,10 +14,18 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -202,31 +210,28 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={true}>
|
||||
<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">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={GoogleSheetLogo}
|
||||
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkSheet)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(linkSheet)}>
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -292,39 +297,37 @@ export const AddIntegrationModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" loading={isLinkingSheet}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.google_sheets.link_google_sheet")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -119,7 +119,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
|
||||
@@ -35,7 +35,7 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -74,13 +74,41 @@ vi.mock("@/modules/ui/components/dropdown-selector", () => ({
|
||||
vi.mock("@/modules/ui/components/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="modal">{children}</div> : null,
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
|
||||
open ? <div data-testid="dialog">{children}</div> : null,
|
||||
DialogContent: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-header" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogDescription: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<p data-testid="dialog-description" className={className}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => (
|
||||
<h2 data-testid="dialog-title">{children}</h2>
|
||||
),
|
||||
DialogBody: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-body" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
DialogFooter: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||
<div data-testid="dialog-footer" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
vi.mock("lucide-react", () => ({
|
||||
PlusIcon: () => <span data-testid="plus-icon">+</span>,
|
||||
XIcon: () => <span data-testid="x-icon">x</span>,
|
||||
TrashIcon: () => <span data-testid="trash-icon">🗑️</span>,
|
||||
}));
|
||||
vi.mock("next/image", () => ({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@@ -208,7 +236,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
{
|
||||
@@ -244,7 +271,6 @@ const surveys: TSurvey[] = [
|
||||
languages: [],
|
||||
welcomeCard: { enabled: true } as unknown as TSurvey["welcomeCard"],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
displayLimit: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
@@ -334,7 +360,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByText("environments.integrations.notion.link_database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toBeInTheDocument();
|
||||
@@ -359,7 +385,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dialog")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-select-a-database")).toHaveValue(databases[0].id);
|
||||
expect(screen.getByTestId("dropdown-select-survey")).toHaveValue(surveys[0].id);
|
||||
expect(screen.getByText("Map Formbricks fields to Notion property")).toBeInTheDocument();
|
||||
@@ -381,7 +407,7 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
expect(columnDropdowns[1]).toHaveValue("p2");
|
||||
|
||||
expect(screen.getAllByTestId("plus-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("x-icon").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId("trash-icon").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
@@ -445,8 +471,8 @@ describe("AddIntegrationModal (Notion)", () => {
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(2);
|
||||
|
||||
const xButton = screen.getAllByTestId("x-icon")[0]; // Get the first X button
|
||||
await userEvent.click(xButton);
|
||||
const trashButton = screen.getAllByTestId("trash-icon")[0]; // Get the first trash button
|
||||
await userEvent.click(trashButton);
|
||||
|
||||
expect(screen.getAllByTestId("dropdown-select-a-survey-question")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -12,11 +12,19 @@ import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Modal } from "@/modules/ui/components/modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, XIcon } from "lucide-react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -336,9 +344,9 @@ export const AddIntegrationModal = ({
|
||||
col={mapping[idx].column}
|
||||
ques={mapping[idx].question}
|
||||
/>
|
||||
<div className="flex w-full items-center">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="w-[340px] max-w-full">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_survey_question")}
|
||||
items={filteredQuestionItems}
|
||||
@@ -384,7 +392,7 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="h-px w-4 border-t border-t-slate-300" />
|
||||
<div className="w-[340px] max-w-full">
|
||||
<div className="max-w-full flex-1">
|
||||
<DropdownSelector
|
||||
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
|
||||
items={getFilteredDbItems()}
|
||||
@@ -430,53 +438,45 @@ export const AddIntegrationModal = ({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded-md p-1 hover:bg-slate-300 ${
|
||||
idx === mapping.length - 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={addRow}>
|
||||
<PlusIcon className="h-5 w-5 font-bold text-slate-500" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`flex-1 rounded-md p-1 hover:bg-red-100 ${
|
||||
mapping.length > 1 ? "visible" : "invisible"
|
||||
}`}
|
||||
onClick={deleteRow}>
|
||||
<XIcon className="h-5 w-5 text-red-500" />
|
||||
</button>
|
||||
<div className="flex space-x-2">
|
||||
{mapping.length > 1 && (
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={deleteRow}>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="icon" className="size-10" onClick={addRow}>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} size="lg">
|
||||
<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">
|
||||
<Image
|
||||
className="w-12"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">
|
||||
{t("environments.integrations.notion.link_notion_database")}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
{t("environments.integrations.notion.sync_responses_with_a_notion_database")}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<div className="mb-4 flex items-start space-x-2">
|
||||
<div className="relative size-8">
|
||||
<Image
|
||||
fill
|
||||
className="object-contain object-center"
|
||||
src={NotionLogo}
|
||||
alt={t("environments.integrations.notion.notion_logo")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.notion.notion_integration_description")}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="w-full">
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(linkDatabase)} className="contents space-y-4">
|
||||
<DialogBody>
|
||||
<div className="w-full space-y-4">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
@@ -521,7 +521,7 @@ export const AddIntegrationModal = ({
|
||||
<Label>
|
||||
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
|
||||
</Label>
|
||||
<div className="mt-4 max-h-[20vh] w-full overflow-y-auto">
|
||||
<div className="mt-1 space-y-2 overflow-y-auto">
|
||||
{mapping.map((_, idx) => (
|
||||
<MappingRow idx={idx} key={idx} />
|
||||
))}
|
||||
@@ -530,43 +530,40 @@ export const AddIntegrationModal = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
setMapping([]);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
{selectedIntegration ? (
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration
|
||||
? t("common.update")
|
||||
: t("environments.integrations.notion.link_database")}
|
||||
type="button"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={() => {
|
||||
deleteLink();
|
||||
}}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
setMapping([]);
|
||||
}}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isLinkingDatabase}
|
||||
disabled={mapping.filter((m) => m.error).length > 0}>
|
||||
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "mock-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -128,7 +128,6 @@ const mockSurveys: TSurvey[] = [
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
@@ -192,9 +191,7 @@ describe("NotionIntegrationPage", () => {
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
);
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent("./");
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
||||
});
|
||||
|
||||
@@ -42,12 +42,12 @@ const Page = async (props) => {
|
||||
const locale = await findMatchingLocale();
|
||||
|
||||
if (isReadOnly) {
|
||||
redirect("./");
|
||||
return redirect("./");
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<GoBackButton url={"./"} />
|
||||
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
|
||||
<NotionWrapper
|
||||
enabled={enabled}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user