mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
287 Commits
fix-broken
...
add-cursor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c84706cca2 | ||
|
|
070dd9f268 | ||
|
|
7a40d647d8 | ||
|
|
2186a1c60d | ||
|
|
2054de4a9d | ||
|
|
e068955fbf | ||
|
|
4f5180ea8f | ||
|
|
093013e1d2 | ||
|
|
8b5b4b4172 | ||
|
|
36c5fc4a65 | ||
|
|
df191de1b4 | ||
|
|
8bb5428548 | ||
|
|
b78f8d0599 | ||
|
|
36535e1e50 | ||
|
|
e26a188d1b | ||
|
|
aaea129d4f | ||
|
|
18f4cd977d | ||
|
|
5468510f5a | ||
|
|
76213af5d7 | ||
|
|
cdf0926c60 | ||
|
|
84b3c57087 | ||
|
|
ed10069b39 | ||
|
|
7c1033af20 | ||
|
|
98e3ad1068 | ||
|
|
b11fbd9f95 | ||
|
|
c5e31d14d1 | ||
|
|
d64d561498 | ||
|
|
1bddc9e960 | ||
|
|
3f122ed9ee | ||
|
|
bdad80d6d1 | ||
|
|
d9ea00d86e | ||
|
|
4a3c2fccba | ||
|
|
3a09af674a | ||
|
|
1ced76c44d | ||
|
|
fa1663d858 | ||
|
|
ebf591a7e0 | ||
|
|
5c9795cd23 | ||
|
|
b67177ba55 | ||
|
|
6cf1f49c8e | ||
|
|
4afb95b92a | ||
|
|
38089241b4 | ||
|
|
07487d4871 | ||
|
|
fa0879e3a0 | ||
|
|
3733c22a6f | ||
|
|
5e5baa76ab | ||
|
|
2153d2aa16 | ||
|
|
7fa4862fd9 | ||
|
|
411e9a26ee | ||
|
|
eb1349f205 | ||
|
|
5c25f25212 | ||
|
|
6af81e46ee | ||
|
|
7423fc9472 | ||
|
|
1557ffcca1 | ||
|
|
5d53ed76ed | ||
|
|
ebd399e611 | ||
|
|
843110b0d6 | ||
|
|
51babf2f98 | ||
|
|
6bc5f1e168 | ||
|
|
c9016802e7 | ||
|
|
6a49fb4700 | ||
|
|
646921cd37 | ||
|
|
34d3145fcd | ||
|
|
c3c06eb309 | ||
|
|
bf4c6238d5 | ||
|
|
8972ef0fef | ||
|
|
4e59924a5a | ||
|
|
8b28353b79 | ||
|
|
abbc7a065b | ||
|
|
00e8ee27a2 | ||
|
|
379aeba71a | ||
|
|
717adddeae | ||
|
|
41798266a0 | ||
|
|
a93fa8ec76 | ||
|
|
47c3df0466 | ||
|
|
935e24bd43 | ||
|
|
3879d86f63 | ||
|
|
839144d338 | ||
|
|
96031822a6 | ||
|
|
21c8b5d6e4 | ||
|
|
22d4952a40 | ||
|
|
933723f1fe | ||
|
|
dd394f1d2c | ||
|
|
0188aad97b | ||
|
|
d46644fe0d | ||
|
|
c259a61f0e | ||
|
|
feee22b5c3 | ||
|
|
a5433f6748 | ||
|
|
557f14bab8 | ||
|
|
fdba260301 | ||
|
|
764b8ec260 | ||
|
|
ac5d1e651e | ||
|
|
62ffcc8e68 | ||
|
|
326872a86b | ||
|
|
892b55662e | ||
|
|
23143c8664 | ||
|
|
4c71caf0da | ||
|
|
173821f846 | ||
|
|
f139830020 | ||
|
|
70979a3b5b | ||
|
|
061fa036be | ||
|
|
b83c0a4a5d | ||
|
|
1bc0563965 | ||
|
|
3a4e2a9f85 | ||
|
|
bd48139a4f | ||
|
|
89fe82a0d6 | ||
|
|
65dc1fa771 | ||
|
|
438990bffc | ||
|
|
7f7bc989c6 | ||
|
|
baa2b31bc9 | ||
|
|
77aecf3aad | ||
|
|
7c1110239b | ||
|
|
eeb337521b | ||
|
|
182f674879 | ||
|
|
73c0da4b75 | ||
|
|
f475b2e6d5 | ||
|
|
e5e8941016 | ||
|
|
c39c9998f0 | ||
|
|
a8c8e6f83f | ||
|
|
8a5e9f38d7 | ||
|
|
a0740d20ea | ||
|
|
71f378a494 | ||
|
|
4bececeb56 | ||
|
|
71c96f48d7 | ||
|
|
05d88a3069 | ||
|
|
b6a63edc88 | ||
|
|
a3764f0316 | ||
|
|
ec52bdf3fe | ||
|
|
2e9ad3ce07 | ||
|
|
654bd232d6 | ||
|
|
01984cf8ca | ||
|
|
3eb18bb120 | ||
|
|
59859d0e4f | ||
|
|
c60c8cb7bd | ||
|
|
9fa7aef253 | ||
|
|
a23594428a | ||
|
|
56e7106d6e | ||
|
|
318f891540 | ||
|
|
a59881f9ae | ||
|
|
7ab4a45ad6 | ||
|
|
2990e3805f | ||
|
|
29132ab029 | ||
|
|
f860d8d25d | ||
|
|
3501990a79 | ||
|
|
41d60c8a02 | ||
|
|
a6269f0fd3 | ||
|
|
9c0d0a16a7 | ||
|
|
c6241f7e7f | ||
|
|
92f1c2b75a | ||
|
|
4d53291c8a | ||
|
|
14b7a69cea | ||
|
|
a9015b008d | ||
|
|
d19d624c0c | ||
|
|
3edaab6c2b | ||
|
|
4786ab61e7 | ||
|
|
819380d21c | ||
|
|
fd3fedb6ed | ||
|
|
88b1e63771 | ||
|
|
3132fe74f1 | ||
|
|
a27a2a67c8 | ||
|
|
4a7ace5a0a | ||
|
|
43628caa3b | ||
|
|
9d84bc0c8d | ||
|
|
babc020085 | ||
|
|
95ee83ef31 | ||
|
|
d994af2dfd | ||
|
|
4b5b5bf59f | ||
|
|
62166dc4b1 | ||
|
|
ec6d88bf11 | ||
|
|
c0240d60a1 | ||
|
|
cd2884d83e | ||
|
|
f7aea2e706 | ||
|
|
e80fc2ee61 | ||
|
|
9b489b0682 | ||
|
|
2ee0efa1c2 | ||
|
|
9ffd67262c | ||
|
|
68dc63ce0b | ||
|
|
f239ee9697 | ||
|
|
282b3e070c | ||
|
|
b5f0bd8f9a | ||
|
|
3784bd6b5e | ||
|
|
41d27c2093 | ||
|
|
7400ce2e67 | ||
|
|
355782f404 | ||
|
|
de70e97940 | ||
|
|
287c45f996 | ||
|
|
3b07a6d013 | ||
|
|
0cc2606ec6 | ||
|
|
0fada94b80 | ||
|
|
a59ede20c7 | ||
|
|
84294f9df2 | ||
|
|
855e7c78ce | ||
|
|
6c506d90c7 | ||
|
|
53f6e02ca1 | ||
|
|
14de2eab42 | ||
|
|
ad1f80331a | ||
|
|
3527ac337b | ||
|
|
23c2d3dce9 | ||
|
|
da652bd860 | ||
|
|
6f88dde1a0 | ||
|
|
3b90223101 | ||
|
|
e29a67b1f6 | ||
|
|
78f5de2f35 | ||
|
|
b1a35d4a69 | ||
|
|
2166c44470 | ||
|
|
080cf741e9 | ||
|
|
8881691509 | ||
|
|
3045f4437f | ||
|
|
91ace0e821 | ||
|
|
6ef281647a | ||
|
|
0aaaaa54ee | ||
|
|
b1f78e7bf2 | ||
|
|
7086ce2ca3 | ||
|
|
8f8b549b1d | ||
|
|
28514487e0 | ||
|
|
ee20af54c3 | ||
|
|
d08ec4c9ab | ||
|
|
891c83e232 | ||
|
|
0b02b00b72 | ||
|
|
a217cdd501 | ||
|
|
ebe50a4821 | ||
|
|
cb68d9defc | ||
|
|
c42a706789 | ||
|
|
3803111b19 | ||
|
|
30fdcff737 | ||
|
|
e83cfa85a4 | ||
|
|
eee9ee8995 | ||
|
|
ed89f12af8 | ||
|
|
f043314537 | ||
|
|
2ce842dd8d | ||
|
|
43b43839c5 | ||
|
|
8b6e3fec37 | ||
|
|
31bcf98779 | ||
|
|
b35cabcbcc | ||
|
|
4f435f1a1f | ||
|
|
99c1e434df | ||
|
|
b13699801b | ||
|
|
ceb2e85d96 | ||
|
|
c5f8b5ec32 | ||
|
|
bdbd57c2fc | ||
|
|
d44aa17814 | ||
|
|
23d38b4c5b | ||
|
|
58213969e8 | ||
|
|
ef973c8995 | ||
|
|
bea02ba3b5 | ||
|
|
1c1e2ee09c | ||
|
|
2bf7fe6c54 | ||
|
|
9639402c39 | ||
|
|
53213b41ee | ||
|
|
b8b5eead7a | ||
|
|
a0044ce376 | ||
|
|
b3a1f24683 | ||
|
|
f06d48698a | ||
|
|
acd508ba19 | ||
|
|
e5591686b4 | ||
|
|
7be7466eee | ||
|
|
8af6c15998 | ||
|
|
17d60eb1e7 | ||
|
|
d6ecafbc23 | ||
|
|
599e847686 | ||
|
|
4e52556f7e | ||
|
|
492a59e7de | ||
|
|
e0be53805e | ||
|
|
5c2860d1a4 | ||
|
|
18ba5bbd8a | ||
|
|
572b613034 | ||
|
|
a9c7140ba6 | ||
|
|
7fa95cd74a | ||
|
|
8c7f36d496 | ||
|
|
42dcbd3e7e | ||
|
|
1c1cd99510 | ||
|
|
b0a7e212dd | ||
|
|
0c1f6f3c3a | ||
|
|
9399b526b8 | ||
|
|
cd60032bc9 | ||
|
|
a941f994ea | ||
|
|
75d170bce5 | ||
|
|
16caae6dd6 | ||
|
|
a490600479 | ||
|
|
be28641722 | ||
|
|
4fdea3221b | ||
|
|
fef30c54b2 | ||
|
|
75362eac7a | ||
|
|
6e3b224944 | ||
|
|
ef1be219b4 | ||
|
|
ba9b01a969 | ||
|
|
e810e38333 | ||
|
|
dab8ad00d5 |
3
.cursor/commands/build.md
Normal file
3
.cursor/commands/build.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
Build the full app and if you run into any errors, fix them and build again until the app builds.
|
||||||
|
|
||||||
|
Complete when: This is complete only when the app build fully.
|
||||||
1
.cursor/commands/pr.md
Normal file
1
.cursor/commands/pr.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Open a draft PR with a concise description of what we’ve done in this PR and what remains to be done.
|
||||||
8
.cursor/commands/unit-tests.md
Normal file
8
.cursor/commands/unit-tests.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Run the unit tests of all files we’ve changed so far:
|
||||||
|
|
||||||
|
1. Check the diff to main
|
||||||
|
2. Determine all files that we changed
|
||||||
|
3. Run the corresponding unit tests
|
||||||
|
4. If tests are failing, update the unit test following the guideline in the corresponding cursor rule.
|
||||||
|
|
||||||
|
Complete when: This is complete when all unit tests pass.
|
||||||
1
.cursor/commands/wrap-up.md
Normal file
1
.cursor/commands/wrap-up.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Run /unit-test and if complete /build and if complete /pr
|
||||||
@@ -16,9 +16,10 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
|||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
### Core Cache Infrastructure
|
### Core Cache Infrastructure
|
||||||
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
|
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
||||||
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
|
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
||||||
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
|
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
||||||
|
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
||||||
|
|
||||||
### Environment State Caching (Critical Endpoint)
|
### Environment State Caching (Critical Endpoint)
|
||||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
||||||
@@ -26,7 +27,7 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
|||||||
|
|
||||||
## Enterprise-Grade Cache Key Patterns
|
## Enterprise-Grade Cache Key Patterns
|
||||||
|
|
||||||
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
|
**Always use** the `createCacheKey` utilities from the cache package:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ Correct patterns
|
// ✅ Correct patterns
|
||||||
@@ -50,14 +51,14 @@ export const getEnterpriseLicense = reactCache(async () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use `withCache()` for Simple Database Queries
|
### Use `cache.withCache()` for Simple Database Queries
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
||||||
export const getActionClasses = (environmentId: string) => {
|
export const getActionClasses = (environmentId: string) => {
|
||||||
return withCache(() => fetchActionClassesFromDB(environmentId), {
|
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
||||||
key: createCacheKey.environment.actionClasses(environmentId),
|
createCacheKey.environment.actionClasses(environmentId),
|
||||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
60 * 30 * 1000 // 30 minutes in milliseconds
|
||||||
})();
|
);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ description: >
|
|||||||
globs: []
|
globs: []
|
||||||
alwaysApply: agent-requested
|
alwaysApply: agent-requested
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# Formbricks Database Schema Reference
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||||
@@ -16,6 +17,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.
|
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||||
|
|
||||||
### Core Hierarchy
|
### Core Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
Organization
|
Organization
|
||||||
└── Project
|
└── Project
|
||||||
@@ -29,6 +31,7 @@ Organization
|
|||||||
## Schema Reference
|
## Schema Reference
|
||||||
|
|
||||||
For the complete and up-to-date database schema, please refer to:
|
For the complete and up-to-date database schema, please refer to:
|
||||||
|
|
||||||
- Main schema: `packages/database/schema.prisma`
|
- Main schema: `packages/database/schema.prisma`
|
||||||
- JSON type definitions: `packages/database/json-types.ts`
|
- JSON type definitions: `packages/database/json-types.ts`
|
||||||
|
|
||||||
@@ -37,17 +40,22 @@ The schema.prisma file contains all model definitions, relationships, enums, and
|
|||||||
## Data Access Patterns
|
## Data Access Patterns
|
||||||
|
|
||||||
### Multi-tenancy
|
### Multi-tenancy
|
||||||
|
|
||||||
- All data is scoped by Organization
|
- All data is scoped by Organization
|
||||||
- Environment-level isolation for surveys and contacts
|
- Environment-level isolation for surveys and contacts
|
||||||
- Project-level grouping for related surveys
|
- Project-level grouping for related surveys
|
||||||
|
|
||||||
### Soft Deletion
|
### Soft Deletion
|
||||||
|
|
||||||
Some models use soft deletion patterns:
|
Some models use soft deletion patterns:
|
||||||
|
|
||||||
- Check `isActive` fields where present
|
- Check `isActive` fields where present
|
||||||
- Use proper filtering in queries
|
- Use proper filtering in queries
|
||||||
|
|
||||||
### Cascading Deletes
|
### Cascading Deletes
|
||||||
|
|
||||||
Configured cascade relationships:
|
Configured cascade relationships:
|
||||||
|
|
||||||
- Organization deletion cascades to all child entities
|
- Organization deletion cascades to all child entities
|
||||||
- Survey deletion removes responses, displays, triggers
|
- Survey deletion removes responses, displays, triggers
|
||||||
- Contact deletion removes attributes and responses
|
- Contact deletion removes attributes and responses
|
||||||
@@ -55,6 +63,7 @@ Configured cascade relationships:
|
|||||||
## Common Query Patterns
|
## Common Query Patterns
|
||||||
|
|
||||||
### Survey with Responses
|
### Survey with Responses
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Include response count and latest responses
|
// Include response count and latest responses
|
||||||
const survey = await prisma.survey.findUnique({
|
const survey = await prisma.survey.findUnique({
|
||||||
@@ -62,40 +71,40 @@ const survey = await prisma.survey.findUnique({
|
|||||||
include: {
|
include: {
|
||||||
responses: {
|
responses: {
|
||||||
take: 10,
|
take: 10,
|
||||||
orderBy: { createdAt: 'desc' }
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { responses: true }
|
select: { responses: true },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Scoping
|
### Environment Scoping
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Always scope by environment
|
// Always scope by environment
|
||||||
const surveys = await prisma.survey.findMany({
|
const surveys = await prisma.survey.findMany({
|
||||||
where: {
|
where: {
|
||||||
environmentId: environmentId,
|
environmentId: environmentId,
|
||||||
// Additional filters...
|
// Additional filters...
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contact with Attributes
|
### Contact with Attributes
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: { id: contactId },
|
where: { id: contactId },
|
||||||
include: {
|
include: {
|
||||||
attributes: {
|
attributes: {
|
||||||
include: {
|
include: {
|
||||||
attributeKey: true
|
attributeKey: true,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,28 @@
|
|||||||
---
|
---
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
title: "FEATURE NAME"
|
title: "FEATURE NAME"
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||||
icon: "link"
|
icon: "link"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
- 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
|
- 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:
|
- If a feature is part of the Enterprise Edition, use this note:
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
FEATURE NAME is part of the @Enterprise Edition.
|
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
||||||
</Note>
|
</Note>
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
---
|
|
||||||
description:
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
# EKS & ALB Optimization Guide for Error Reduction
|
|
||||||
|
|
||||||
## Infrastructure Overview
|
|
||||||
|
|
||||||
This project uses AWS EKS with Application Load Balancer (ALB) for the Formbricks application. The infrastructure has been optimized to minimize ELB 502/504 errors through careful configuration of connection handling, health checks, and pod lifecycle management.
|
|
||||||
|
|
||||||
## Key Infrastructure Files
|
|
||||||
|
|
||||||
### Terraform Configuration
|
|
||||||
- **Main Infrastructure**: [infra/terraform/main.tf](mdc:infra/terraform/main.tf) - EKS cluster, VPC, Karpenter, and core AWS resources
|
|
||||||
- **Monitoring**: [infra/terraform/cloudwatch.tf](mdc:infra/terraform/cloudwatch.tf) - CloudWatch alarms for 502/504 error tracking and alerting
|
|
||||||
- **Database**: [infra/terraform/rds.tf](mdc:infra/terraform/rds.tf) - Aurora PostgreSQL configuration
|
|
||||||
|
|
||||||
### Helm Configuration
|
|
||||||
- **Production**: [infra/formbricks-cloud-helm/values.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values.yaml.gotmpl) - Optimized ALB and pod configurations
|
|
||||||
- **Staging**: [infra/formbricks-cloud-helm/values-staging.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/values-staging.yaml.gotmpl) - Staging environment with spot instances
|
|
||||||
- **Deployment**: [infra/formbricks-cloud-helm/helmfile.yaml.gotmpl](mdc:infra/formbricks-cloud-helm/helmfile.yaml.gotmpl) - Multi-environment Helm releases
|
|
||||||
|
|
||||||
## ALB Optimization Patterns
|
|
||||||
|
|
||||||
### Connection Handling Optimizations
|
|
||||||
```yaml
|
|
||||||
# Key ALB annotations for reducing 502/504 errors
|
|
||||||
alb.ingress.kubernetes.io/load-balancer-attributes: |
|
|
||||||
idle_timeout.timeout_seconds=120,
|
|
||||||
connection_logs.s3.enabled=false,
|
|
||||||
access_logs.s3.enabled=false
|
|
||||||
|
|
||||||
alb.ingress.kubernetes.io/target-group-attributes: |
|
|
||||||
deregistration_delay.timeout_seconds=30,
|
|
||||||
stickiness.enabled=false,
|
|
||||||
load_balancing.algorithm.type=least_outstanding_requests,
|
|
||||||
target_group_health.dns_failover.minimum_healthy_targets.count=1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Check Configuration
|
|
||||||
- **Interval**: 15 seconds for faster detection of unhealthy targets
|
|
||||||
- **Timeout**: 5 seconds to prevent false positives
|
|
||||||
- **Thresholds**: 2 healthy, 3 unhealthy for balanced responsiveness
|
|
||||||
- **Path**: `/health` endpoint optimized for < 100ms response time
|
|
||||||
|
|
||||||
## Pod Lifecycle Management
|
|
||||||
|
|
||||||
### Graceful Shutdown Pattern
|
|
||||||
```yaml
|
|
||||||
# PreStop hook to allow connection draining
|
|
||||||
lifecycle:
|
|
||||||
preStop:
|
|
||||||
exec:
|
|
||||||
command: ["/bin/sh", "-c", "sleep 15"]
|
|
||||||
|
|
||||||
# Termination grace period for complete cleanup
|
|
||||||
terminationGracePeriodSeconds: 45
|
|
||||||
```
|
|
||||||
|
|
||||||
### Health Probe Strategy
|
|
||||||
- **Startup Probe**: 5s initial delay, 5s interval, max 60s startup time
|
|
||||||
- **Readiness Probe**: 10s delay, 10s interval for traffic readiness
|
|
||||||
- **Liveness Probe**: 30s delay, 30s interval for container health
|
|
||||||
|
|
||||||
### Rolling Update Configuration
|
|
||||||
```yaml
|
|
||||||
strategy:
|
|
||||||
type: RollingUpdate
|
|
||||||
rollingUpdate:
|
|
||||||
maxUnavailable: 25% # Maintain capacity during updates
|
|
||||||
maxSurge: 50% # Allow faster rollouts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Karpenter Node Management
|
|
||||||
|
|
||||||
### Node Lifecycle Optimization
|
|
||||||
- **Startup Taints**: Prevent traffic during node initialization
|
|
||||||
- **Graceful Shutdown**: 30s grace period for pod eviction
|
|
||||||
- **Consolidation Delay**: 60s to reduce unnecessary churn
|
|
||||||
- **Eviction Policies**: Configured for smooth pod migrations
|
|
||||||
|
|
||||||
### Instance Selection
|
|
||||||
- **Families**: c8g, c7g, m8g, m7g, r8g, r7g (ARM64 Graviton)
|
|
||||||
- **Sizes**: 2, 4, 8 vCPUs for cost optimization
|
|
||||||
- **Bottlerocket AMI**: Enhanced security and performance
|
|
||||||
|
|
||||||
## Monitoring & Alerting
|
|
||||||
|
|
||||||
### Critical ALB Metrics
|
|
||||||
1. **ELB 502 Errors**: Threshold 20 over 5 minutes
|
|
||||||
2. **ELB 504 Errors**: Threshold 15 over 5 minutes
|
|
||||||
3. **Target Connection Errors**: Threshold 50 over 5 minutes
|
|
||||||
4. **4XX Errors**: Threshold 100 over 10 minutes (client issues)
|
|
||||||
|
|
||||||
### Expected Improvements
|
|
||||||
- **60-80% reduction** in ELB 502 errors
|
|
||||||
- **Faster recovery** during pod restarts
|
|
||||||
- **Better connection reuse** efficiency
|
|
||||||
- **Improved autoscaling** responsiveness
|
|
||||||
|
|
||||||
## Deployment Patterns
|
|
||||||
|
|
||||||
### Infrastructure Updates
|
|
||||||
1. **Terraform First**: Apply infrastructure changes via [infra/deploy-improvements.sh](mdc:infra/deploy-improvements.sh)
|
|
||||||
2. **Helm Second**: Deploy application configurations
|
|
||||||
3. **Verification**: Check pod status, endpoints, and ALB health
|
|
||||||
4. **Monitoring**: Watch CloudWatch metrics for 24-48 hours
|
|
||||||
|
|
||||||
### Environment-Specific Configurations
|
|
||||||
- **Production**: On-demand instances, stricter resource limits
|
|
||||||
- **Staging**: Spot instances, rate limiting disabled, relaxed resources
|
|
||||||
|
|
||||||
## Troubleshooting Patterns
|
|
||||||
|
|
||||||
### 502 Error Investigation
|
|
||||||
1. Check pod readiness and health probe status
|
|
||||||
2. Verify ALB target group health
|
|
||||||
3. Review deregistration timing during deployments
|
|
||||||
4. Monitor connection pool utilization
|
|
||||||
|
|
||||||
### 504 Error Analysis
|
|
||||||
1. Check application response times
|
|
||||||
2. Verify timeout configurations (ALB: 120s, App: aligned)
|
|
||||||
3. Review database query performance
|
|
||||||
4. Monitor resource utilization during traffic spikes
|
|
||||||
|
|
||||||
### Connection Error Patterns
|
|
||||||
1. Verify Karpenter node lifecycle timing
|
|
||||||
2. Check pod termination grace periods
|
|
||||||
3. Review ALB connection draining settings
|
|
||||||
4. Monitor cluster autoscaling events
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
### When Making Changes
|
|
||||||
- **Test in staging first** with same configurations
|
|
||||||
- **Monitor metrics** for 24-48 hours after changes
|
|
||||||
- **Use gradual rollouts** with proper health checks
|
|
||||||
- **Maintain ALB timeout alignment** across all layers
|
|
||||||
|
|
||||||
### Performance Optimization
|
|
||||||
- **Health endpoint** should respond < 100ms consistently
|
|
||||||
- **Connection pooling** aligned with ALB idle timeouts
|
|
||||||
- **Resource requests/limits** tuned for consistent performance
|
|
||||||
- **Graceful shutdown** implemented in application code
|
|
||||||
|
|
||||||
### Monitoring Strategy
|
|
||||||
- **Real-time alerts** for error rate spikes
|
|
||||||
- **Trend analysis** for connection patterns
|
|
||||||
- **Capacity planning** based on LCU usage
|
|
||||||
- **4XX pattern analysis** for client behavior insights
|
|
||||||
@@ -18,7 +18,6 @@ apps/web/
|
|||||||
│ ├── (app)/ # Main application routes
|
│ ├── (app)/ # Main application routes
|
||||||
│ ├── (auth)/ # Authentication routes
|
│ ├── (auth)/ # Authentication routes
|
||||||
│ ├── api/ # API routes
|
│ ├── api/ # API routes
|
||||||
│ └── share/ # Public sharing routes
|
|
||||||
├── components/ # Shared components
|
├── components/ # Shared components
|
||||||
├── lib/ # Utility functions and services
|
├── lib/ # Utility functions and services
|
||||||
└── modules/ # Feature-specific modules
|
└── modules/ # Feature-specific modules
|
||||||
@@ -43,7 +42,6 @@ The application uses Next.js 13+ app router with route groups:
|
|||||||
### Dynamic Routes
|
### Dynamic Routes
|
||||||
- `[environmentId]` - Environment-specific routes
|
- `[environmentId]` - Environment-specific routes
|
||||||
- `[surveyId]` - Survey-specific routes
|
- `[surveyId]` - Survey-specific routes
|
||||||
- `[sharingKey]` - Public sharing routes
|
|
||||||
|
|
||||||
## Service Layer Pattern
|
## Service Layer Pattern
|
||||||
|
|
||||||
|
|||||||
232
.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)
|
||||||
179
.cursor/rules/review-and-refine.mdc
Normal file
179
.cursor/rules/review-and-refine.mdc
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
---
|
||||||
|
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
|
||||||
|
globs:
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review & Refine
|
||||||
|
|
||||||
|
Before finalizing any code changes, review your implementation against these quality standards:
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### DRY (Don't Repeat Yourself)
|
||||||
|
|
||||||
|
- Extract duplicated logic into reusable functions or hooks
|
||||||
|
- If the same code appears in multiple places, consolidate it
|
||||||
|
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
|
||||||
|
- Avoid copy-pasting code blocks
|
||||||
|
|
||||||
|
### Code Reduction
|
||||||
|
|
||||||
|
- Remove unnecessary code, comments, and abstractions
|
||||||
|
- Prefer built-in solutions over custom implementations
|
||||||
|
- Consolidate similar logic
|
||||||
|
- Remove dead code and unused imports
|
||||||
|
- Question if every line of code is truly needed
|
||||||
|
|
||||||
|
## React Best Practices
|
||||||
|
|
||||||
|
### Component Design
|
||||||
|
|
||||||
|
- Keep components focused on a single responsibility
|
||||||
|
- Extract complex logic into custom hooks
|
||||||
|
- Prefer composition over prop drilling
|
||||||
|
- Use children props and render props when appropriate
|
||||||
|
- Keep component files under 300 lines when possible
|
||||||
|
|
||||||
|
### Hooks Usage
|
||||||
|
|
||||||
|
- Follow Rules of Hooks (only call at top level, only in React functions)
|
||||||
|
- Extract complex `useEffect` logic into custom hooks
|
||||||
|
- Use `useMemo` and `useCallback` only when you have a measured performance issue
|
||||||
|
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
|
||||||
|
- Keep `useEffect` focused on a single concern
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
- Colocate state as close as possible to where it's used
|
||||||
|
- Lift state only when necessary
|
||||||
|
- Use `useReducer` for complex state logic with multiple sub-values
|
||||||
|
- Avoid derived state - compute values during render instead
|
||||||
|
- Don't store values in state that can be computed from props
|
||||||
|
|
||||||
|
### Event Handlers
|
||||||
|
|
||||||
|
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
|
||||||
|
- Extract complex event handler logic into separate functions
|
||||||
|
- Avoid inline arrow functions in JSX when they contain complex logic
|
||||||
|
|
||||||
|
## TypeScript Best Practices
|
||||||
|
|
||||||
|
### Type Safety
|
||||||
|
|
||||||
|
- Prefer type inference over explicit types when possible
|
||||||
|
- Use `const` assertions for literal types
|
||||||
|
- Avoid `any` - use `unknown` if type is truly unknown
|
||||||
|
- Use discriminated unions for complex conditional logic
|
||||||
|
- Leverage type guards and narrowing
|
||||||
|
|
||||||
|
### Interface & Type Usage
|
||||||
|
|
||||||
|
- Use existing types from `@formbricks/types` - don't recreate them
|
||||||
|
- Prefer `interface` for object shapes that might be extended
|
||||||
|
- Prefer `type` for unions, intersections, and mapped types
|
||||||
|
- Define types close to where they're used unless they're shared
|
||||||
|
- Export types from index files for shared types
|
||||||
|
|
||||||
|
### Type Assertions
|
||||||
|
|
||||||
|
- Avoid type assertions (`as`) when possible
|
||||||
|
- Use type guards instead of assertions
|
||||||
|
- Only assert when you have more information than TypeScript
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
|
||||||
|
- Separate business logic from UI rendering
|
||||||
|
- Extract API calls into separate functions or modules
|
||||||
|
- Keep data transformation separate from component logic
|
||||||
|
- Use custom hooks for stateful logic that doesn't render UI
|
||||||
|
|
||||||
|
### Function Clarity
|
||||||
|
|
||||||
|
- Functions should do one thing well
|
||||||
|
- Name functions clearly and descriptively
|
||||||
|
- Keep functions small (aim for under 20 lines)
|
||||||
|
- Extract complex conditionals into named boolean variables or functions
|
||||||
|
- Avoid deep nesting (max 3 levels)
|
||||||
|
|
||||||
|
### File Structure
|
||||||
|
|
||||||
|
- Group related functions together
|
||||||
|
- Order declarations logically (types → hooks → helpers → component)
|
||||||
|
- Keep imports organized (external → internal → relative)
|
||||||
|
- Consider splitting large files by concern
|
||||||
|
|
||||||
|
## Additional Quality Checks
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
- Don't optimize prematurely - measure first
|
||||||
|
- Avoid creating new objects/arrays/functions in render unnecessarily
|
||||||
|
- Use keys properly in lists (stable, unique identifiers)
|
||||||
|
- Lazy load heavy components when appropriate
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- Use semantic HTML elements
|
||||||
|
- Include ARIA labels where needed
|
||||||
|
- Ensure keyboard navigation works
|
||||||
|
- Check color contrast and focus states
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Handle error states in components
|
||||||
|
- Provide user feedback for failed operations
|
||||||
|
- Use error boundaries for component errors
|
||||||
|
- Log errors appropriately (avoid swallowing errors silently)
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- Use descriptive names (avoid abbreviations unless very common)
|
||||||
|
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
|
||||||
|
- Arrays should be plural (`users`, `choices`, `items`)
|
||||||
|
- Event handlers: `handleX` in components, `onX` for props
|
||||||
|
- Constants in UPPER_SNAKE_CASE only for true constants
|
||||||
|
|
||||||
|
### Code Readability
|
||||||
|
|
||||||
|
- Prefer early returns to reduce nesting
|
||||||
|
- Use destructuring to make code clearer
|
||||||
|
- Break complex expressions into named variables
|
||||||
|
- Add comments only when code can't be made self-explanatory
|
||||||
|
- Use whitespace to group related code
|
||||||
|
|
||||||
|
### Testing Considerations
|
||||||
|
|
||||||
|
- Write code that's easy to test (pure functions, clear inputs/outputs)
|
||||||
|
- Avoid hard-to-mock dependencies when possible
|
||||||
|
- Keep side effects at the edges of your code
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
Before submitting your changes, ask yourself:
|
||||||
|
|
||||||
|
1. **DRY**: Is there any duplicated logic I can extract?
|
||||||
|
2. **Clarity**: Would another developer understand this code easily?
|
||||||
|
3. **Simplicity**: Is this the simplest solution that works?
|
||||||
|
4. **Types**: Am I using TypeScript effectively?
|
||||||
|
5. **React**: Am I following React idioms and best practices?
|
||||||
|
6. **Performance**: Are there obvious performance issues?
|
||||||
|
7. **Separation**: Are concerns properly separated?
|
||||||
|
8. **Testing**: Is this code testable?
|
||||||
|
9. **Maintenance**: Will this be easy to change in 6 months?
|
||||||
|
10. **Deletion**: Can I remove any code and still accomplish the goal?
|
||||||
|
|
||||||
|
## When to Apply This Rule
|
||||||
|
|
||||||
|
Apply this rule:
|
||||||
|
|
||||||
|
- After implementing a feature but before marking it complete
|
||||||
|
- When you notice your code feels "messy" or complex
|
||||||
|
- Before requesting code review
|
||||||
|
- When you see yourself copy-pasting code
|
||||||
|
- After receiving feedback about code quality
|
||||||
|
|
||||||
|
Don't let perfect be the enemy of good, but always strive for:
|
||||||
|
**Simple, readable, maintainable code that does one thing well.**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
description:
|
description: Migrate deprecated UI components to a unified component
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
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({
|
vi.mocked(useResponseFilter).mockReturnValue({
|
||||||
selectedFilter: {
|
selectedFilter: {
|
||||||
filter: [],
|
filter: [],
|
||||||
onlyComplete: false,
|
responseStatus: "all",
|
||||||
},
|
},
|
||||||
setSelectedFilter: vi.fn(),
|
setSelectedFilter: vi.fn(),
|
||||||
selectedOptions: {
|
selectedOptions: {
|
||||||
@@ -291,11 +291,6 @@ test("handles different modes", async () => {
|
|||||||
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
expect(vi.mocked(regularApi)).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test sharing mode
|
|
||||||
vi.mocked(useParams).mockReturnValue({
|
|
||||||
surveyId: "123",
|
|
||||||
sharingKey: "share-123"
|
|
||||||
});
|
|
||||||
rerender();
|
rerender();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
13
.env.example
13
.env.example
@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
|
|||||||
|
|
||||||
# Uncomment the variables you would like to use and customize the values.
|
# Uncomment the variables you would like to use and customize the values.
|
||||||
|
|
||||||
# Custom local storage path for file uploads
|
|
||||||
#UPLOADS_DIR=
|
|
||||||
|
|
||||||
##############
|
##############
|
||||||
# S3 STORAGE #
|
# S3 STORAGE #
|
||||||
##############
|
##############
|
||||||
@@ -99,8 +96,6 @@ PASSWORD_RESET_DISABLED=1
|
|||||||
# Organization Invite. Disable the ability for invited users to create an account.
|
# Organization Invite. Disable the ability for invited users to create an account.
|
||||||
# INVITE_DISABLED=1
|
# INVITE_DISABLED=1
|
||||||
|
|
||||||
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
|
|
||||||
# DOCKER_CRON_ENABLED=1
|
|
||||||
|
|
||||||
##########
|
##########
|
||||||
# Other #
|
# Other #
|
||||||
@@ -189,15 +184,11 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
|
|
||||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
|
|
||||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||||
# REDIS_HTTP_URL:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
# The below is used for Rate Limiting for management API
|
|
||||||
UNKEY_ROOT_KEY=
|
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# INTERCOM_APP_ID=
|
||||||
# INTERCOM_SECRET_KEY=
|
# INTERCOM_SECRET_KEY=
|
||||||
|
|
||||||
@@ -210,6 +201,8 @@ UNKEY_ROOT_KEY=
|
|||||||
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||||
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||||
# SENTRY_AUTH_TOKEN=
|
# SENTRY_AUTH_TOKEN=
|
||||||
|
# The SENTRY_ENVIRONMENT is the environment which the error will belong to in the Sentry dashboard
|
||||||
|
# SENTRY_ENVIRONMENT=
|
||||||
|
|
||||||
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
# Configure the minimum role for user management from UI(owner, manager, disabled)
|
||||||
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
# USER_MANAGEMENT_MINIMUM_ROLE="manager"
|
||||||
@@ -217,7 +210,7 @@ UNKEY_ROOT_KEY=
|
|||||||
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
# Configure the maximum age for the session in seconds. Default is 86400 (24 hours)
|
||||||
# SESSION_MAX_AGE=86400
|
# SESSION_MAX_AGE=86400
|
||||||
|
|
||||||
# Audit logs options. Requires REDIS_URL env varibale. Default 0.
|
# Audit logs options. Default 0.
|
||||||
# AUDIT_LOG_ENABLED=0
|
# AUDIT_LOG_ENABLED=0
|
||||||
# If the ip should be added in the log or not. Default 0
|
# If the ip should be added in the log or not. Default 0
|
||||||
# AUDIT_LOG_GET_USER_IP=0
|
# AUDIT_LOG_GET_USER_IP=0
|
||||||
|
|||||||
13
.eslintrc.cjs
Normal file
13
.eslintrc.cjs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
ignorePatterns: ["node_modules/", "dist/", "coverage/"],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["packages/cache/**/*.{ts,js}"],
|
||||||
|
extends: ["@formbricks/eslint-config/library.js"],
|
||||||
|
parserOptions: {
|
||||||
|
project: "./packages/cache/tsconfig.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -1,6 +1,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
description: "Found a bug? Please fill out the sections below. \U0001F44D"
|
||||||
type: bug
|
type: bug
|
||||||
|
projects: "formbricks/8"
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
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:
|
contact_links:
|
||||||
- name: Questions
|
- name: Questions
|
||||||
url: https://github.com/formbricks/formbricks/discussions
|
url: https://github.com/formbricks/formbricks/discussions
|
||||||
|
|||||||
319
.github/actions/build-and-push-docker/action.yml
vendored
Normal file
319
.github/actions/build-and-push-docker/action.yml
vendored
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
description: |
|
||||||
|
Unified Docker build and push action for both ECR and GHCR registries.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ECR builds for Formbricks Cloud deployment
|
||||||
|
- GHCR builds for community self-hosting
|
||||||
|
- Automatic version resolution and tagging
|
||||||
|
- Conditional signing and deployment tags
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
registry_type:
|
||||||
|
description: "Registry type: 'ecr' or 'ghcr'"
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# Version input
|
||||||
|
version:
|
||||||
|
description: "Explicit version (SemVer only, e.g., 1.2.3). If provided, this version is used directly. If empty, version is auto-generated from branch name."
|
||||||
|
required: false
|
||||||
|
experimental_mode:
|
||||||
|
description: "Enable experimental timestamped versions"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
# ECR specific inputs
|
||||||
|
ecr_registry:
|
||||||
|
description: "ECR registry URL (required for ECR builds)"
|
||||||
|
required: false
|
||||||
|
ecr_repository:
|
||||||
|
description: "ECR repository name (required for ECR builds)"
|
||||||
|
required: false
|
||||||
|
ecr_region:
|
||||||
|
description: "ECR AWS region (required for ECR builds)"
|
||||||
|
required: false
|
||||||
|
aws_role_arn:
|
||||||
|
description: "AWS role ARN for ECR authentication (required for ECR builds)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
# GHCR specific inputs
|
||||||
|
ghcr_image_name:
|
||||||
|
description: "GHCR image name (required for GHCR builds)"
|
||||||
|
required: false
|
||||||
|
|
||||||
|
# Deployment options
|
||||||
|
deploy_production:
|
||||||
|
description: "Tag image for production deployment"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
deploy_staging:
|
||||||
|
description: "Tag image for staging deployment"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
is_prerelease:
|
||||||
|
description: "Whether this is a prerelease (auto-tags for staging/production)"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
make_latest:
|
||||||
|
description: "Whether to tag as latest/production (from GitHub release 'Set as the latest release' option)"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
# Build options
|
||||||
|
dockerfile:
|
||||||
|
description: "Path to Dockerfile"
|
||||||
|
required: false
|
||||||
|
default: "apps/web/Dockerfile"
|
||||||
|
context:
|
||||||
|
description: "Build context"
|
||||||
|
required: false
|
||||||
|
default: "."
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Resolved image tag used for the build"
|
||||||
|
value: ${{ steps.version.outputs.version }}
|
||||||
|
registry_tags:
|
||||||
|
description: "Complete registry tags that were pushed"
|
||||||
|
value: ${{ steps.build.outputs.tags }}
|
||||||
|
image_digest:
|
||||||
|
description: "Image digest from the build"
|
||||||
|
value: ${{ steps.build.outputs.digest }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Validate inputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_TYPE: ${{ inputs.registry_type }}
|
||||||
|
ECR_REGISTRY: ${{ inputs.ecr_registry }}
|
||||||
|
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
|
||||||
|
ECR_REGION: ${{ inputs.ecr_region }}
|
||||||
|
AWS_ROLE_ARN: ${{ inputs.aws_role_arn }}
|
||||||
|
GHCR_IMAGE_NAME: ${{ inputs.ghcr_image_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "$REGISTRY_TYPE" != "ecr" && "$REGISTRY_TYPE" != "ghcr" ]]; then
|
||||||
|
echo "ERROR: registry_type must be 'ecr' or 'ghcr', got: $REGISTRY_TYPE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
|
||||||
|
if [[ -z "$ECR_REGISTRY" || -z "$ECR_REPOSITORY" || -z "$ECR_REGION" || -z "$AWS_ROLE_ARN" ]]; then
|
||||||
|
echo "ERROR: ECR builds require ecr_registry, ecr_repository, ecr_region, and aws_role_arn"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$REGISTRY_TYPE" == "ghcr" ]]; then
|
||||||
|
if [[ -z "$GHCR_IMAGE_NAME" ]]; then
|
||||||
|
echo "ERROR: GHCR builds require ghcr_image_name"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SUCCESS: Input validation passed for $REGISTRY_TYPE build"
|
||||||
|
|
||||||
|
- name: Resolve Docker version
|
||||||
|
id: version
|
||||||
|
uses: ./.github/actions/resolve-docker-version
|
||||||
|
with:
|
||||||
|
version: ${{ inputs.version }}
|
||||||
|
current_branch: ${{ github.ref_name }}
|
||||||
|
experimental_mode: ${{ inputs.experimental_mode }}
|
||||||
|
|
||||||
|
- name: Update package.json version
|
||||||
|
uses: ./.github/actions/update-package-version
|
||||||
|
with:
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Configure AWS credentials (ECR only)
|
||||||
|
if: ${{ inputs.registry_type == 'ecr' }}
|
||||||
|
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4.2.0
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ inputs.aws_role_arn }}
|
||||||
|
aws-region: ${{ inputs.ecr_region }}
|
||||||
|
|
||||||
|
- name: Log in to Amazon ECR (ECR only)
|
||||||
|
if: ${{ inputs.registry_type == 'ecr' }}
|
||||||
|
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
|
||||||
|
|
||||||
|
- name: Set up Docker build tools
|
||||||
|
uses: ./.github/actions/docker-build-setup
|
||||||
|
with:
|
||||||
|
registry: ${{ inputs.registry_type == 'ghcr' && 'ghcr.io' || '' }}
|
||||||
|
setup_cosign: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
|
||||||
|
skip_login_on_pr: ${{ inputs.registry_type == 'ghcr' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Build ECR tag list
|
||||||
|
if: ${{ inputs.registry_type == 'ecr' }}
|
||||||
|
id: ecr-tags
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ steps.version.outputs.version }}
|
||||||
|
ECR_REGISTRY: ${{ inputs.ecr_registry }}
|
||||||
|
ECR_REPOSITORY: ${{ inputs.ecr_repository }}
|
||||||
|
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
||||||
|
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.is_prerelease }}
|
||||||
|
MAKE_LATEST: ${{ inputs.make_latest }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Start with the base image tag
|
||||||
|
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
# Handle automatic tagging based on release type
|
||||||
|
if [[ "${IS_PRERELEASE}" == "true" ]]; then
|
||||||
|
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||||
|
echo "Adding staging tag for prerelease"
|
||||||
|
elif [[ "${IS_PRERELEASE}" == "false" && "${MAKE_LATEST}" == "true" ]]; then
|
||||||
|
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||||
|
echo "Adding production tag for stable release marked as latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Handle manual deployment overrides
|
||||||
|
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
||||||
|
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||||
|
echo "Adding production tag (manual override)"
|
||||||
|
fi
|
||||||
|
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
||||||
|
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||||
|
echo "Adding staging tag (manual override)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "ECR tags generated:"
|
||||||
|
echo -e "${TAGS}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "tags<<EOF"
|
||||||
|
echo -e "${TAGS}"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Generate additional GHCR tags for releases
|
||||||
|
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||||
|
id: ghcr-extra-tags
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
|
IMAGE_NAME: ${{ inputs.ghcr_image_name }}
|
||||||
|
IS_PRERELEASE: ${{ inputs.is_prerelease }}
|
||||||
|
MAKE_LATEST: ${{ inputs.make_latest }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Start with base version tag
|
||||||
|
TAGS="ghcr.io/${IMAGE_NAME}:${VERSION}"
|
||||||
|
|
||||||
|
# For proper SemVer releases, add major.minor and major tags
|
||||||
|
if [[ "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
# Extract major and minor versions
|
||||||
|
MAJOR=$(echo "${VERSION}" | cut -d. -f1)
|
||||||
|
MINOR=$(echo "${VERSION}" | cut -d. -f2)
|
||||||
|
|
||||||
|
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}.${MINOR}"
|
||||||
|
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:${MAJOR}"
|
||||||
|
|
||||||
|
echo "Added SemVer tags: ${MAJOR}.${MINOR}, ${MAJOR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add latest tag for stable releases marked as latest
|
||||||
|
if [[ "${IS_PRERELEASE}" == "false" && "${MAKE_LATEST}" == "true" ]]; then
|
||||||
|
TAGS="${TAGS}\nghcr.io/${IMAGE_NAME}:latest"
|
||||||
|
echo "Added latest tag for stable release marked as latest"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Generated GHCR tags:"
|
||||||
|
echo -e "${TAGS}"
|
||||||
|
|
||||||
|
# Debug: Show what will be passed to Docker build
|
||||||
|
echo "DEBUG: Tags for Docker build step:"
|
||||||
|
echo -e "${TAGS}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "tags<<EOF"
|
||||||
|
echo -e "${TAGS}"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
- name: Build GHCR metadata (experimental)
|
||||||
|
if: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' }}
|
||||||
|
id: ghcr-meta-experimental
|
||||||
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ inputs.ghcr_image_name }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=raw,value=${{ steps.version.outputs.version }}
|
||||||
|
|
||||||
|
- name: Debug Docker build tags
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "=== DEBUG: Docker Build Configuration ==="
|
||||||
|
echo "Registry Type: ${{ inputs.registry_type }}"
|
||||||
|
echo "Experimental Mode: ${{ inputs.experimental_mode }}"
|
||||||
|
echo "Event Name: ${{ github.event_name }}"
|
||||||
|
echo "Is Prerelease: ${{ inputs.is_prerelease }}"
|
||||||
|
echo "Make Latest: ${{ inputs.make_latest }}"
|
||||||
|
echo "Version: ${{ steps.version.outputs.version }}"
|
||||||
|
|
||||||
|
if [[ "${{ inputs.registry_type }}" == "ecr" ]]; then
|
||||||
|
echo "ECR Tags: ${{ steps.ecr-tags.outputs.tags }}"
|
||||||
|
elif [[ "${{ inputs.experimental_mode }}" == "true" ]]; then
|
||||||
|
echo "GHCR Experimental Tags: ${{ steps.ghcr-meta-experimental.outputs.tags }}"
|
||||||
|
else
|
||||||
|
echo "GHCR Extra Tags: ${{ steps.ghcr-extra-tags.outputs.tags }}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: build
|
||||||
|
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||||
|
with:
|
||||||
|
project: tw0fqmsx3c
|
||||||
|
token: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||||
|
context: ${{ inputs.context }}
|
||||||
|
file: ${{ inputs.dockerfile }}
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
|
tags: ${{ inputs.registry_type == 'ecr' && steps.ecr-tags.outputs.tags || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags) || (inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'false' && steps.ghcr-extra-tags.outputs.tags) || (inputs.registry_type == 'ghcr' && format('ghcr.io/{0}:{1}', inputs.ghcr_image_name, steps.version.outputs.version)) || (inputs.registry_type == 'ecr' && format('{0}/{1}:{2}', inputs.ecr_registry, inputs.ecr_repository, steps.version.outputs.version)) }}
|
||||||
|
labels: ${{ inputs.registry_type == 'ghcr' && inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.labels || '' }}
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ env.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||||
|
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
env:
|
||||||
|
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||||
|
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||||
|
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
|
||||||
|
- name: Sign GHCR image (GHCR only)
|
||||||
|
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
TAGS: ${{ inputs.experimental_mode == 'true' && steps.ghcr-meta-experimental.outputs.tags || steps.ghcr-extra-tags.outputs.tags }}
|
||||||
|
DIGEST: ${{ steps.build.outputs.digest }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||||
|
|
||||||
|
- name: Output build summary
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY_TYPE: ${{ inputs.registry_type }}
|
||||||
|
IMAGE_TAG: ${{ steps.version.outputs.version }}
|
||||||
|
VERSION_SOURCE: ${{ steps.version.outputs.source }}
|
||||||
|
run: |
|
||||||
|
echo "SUCCESS: Built and pushed Docker image to $REGISTRY_TYPE"
|
||||||
|
echo "Image Tag: $IMAGE_TAG (source: $VERSION_SOURCE)"
|
||||||
|
if [[ "$REGISTRY_TYPE" == "ecr" ]]; then
|
||||||
|
echo "ECR Registry: ${{ inputs.ecr_registry }}"
|
||||||
|
echo "ECR Repository: ${{ inputs.ecr_repository }}"
|
||||||
|
else
|
||||||
|
echo "GHCR Image: ghcr.io/${{ inputs.ghcr_image_name }}"
|
||||||
|
fi
|
||||||
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
|
shell: bash
|
||||||
|
|
||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||||
|
env:
|
||||||
|
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
106
.github/actions/docker-build-setup/action.yml
vendored
Normal file
106
.github/actions/docker-build-setup/action.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
name: Docker Build Setup
|
||||||
|
description: |
|
||||||
|
Sets up common Docker build tools and authentication with security validation.
|
||||||
|
|
||||||
|
Security Features:
|
||||||
|
- Registry URL validation
|
||||||
|
- Input sanitization
|
||||||
|
- Conditional setup based on event type
|
||||||
|
- Post-setup verification
|
||||||
|
|
||||||
|
Supports Depot CLI, Cosign signing, and Docker registry authentication.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
registry:
|
||||||
|
description: "Docker registry hostname to login to (e.g., ghcr.io, registry.example.com:5000). No paths allowed."
|
||||||
|
required: false
|
||||||
|
default: "ghcr.io"
|
||||||
|
setup_cosign:
|
||||||
|
description: "Whether to install cosign for image signing"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
skip_login_on_pr:
|
||||||
|
description: "Whether to skip registry login on pull requests"
|
||||||
|
required: false
|
||||||
|
default: "true"
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Validate inputs
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ inputs.registry }}
|
||||||
|
SETUP_COSIGN: ${{ inputs.setup_cosign }}
|
||||||
|
SKIP_LOGIN_ON_PR: ${{ inputs.skip_login_on_pr }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Security: Validate registry input - must be hostname[:port] only, no paths
|
||||||
|
# Allow empty registry for cases where login is handled externally (e.g., ECR)
|
||||||
|
if [[ -n "$REGISTRY" ]]; then
|
||||||
|
if [[ "$REGISTRY" =~ / ]]; then
|
||||||
|
echo "ERROR: Invalid registry format: $REGISTRY"
|
||||||
|
echo "Registry must be host[:port] with no path (e.g., 'ghcr.io' or 'registry.example.com:5000')"
|
||||||
|
echo "Path components like 'ghcr.io/org' are not allowed as they break docker login"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate hostname with optional port format
|
||||||
|
if [[ ! "$REGISTRY" =~ ^[a-zA-Z0-9.-]+(\:[0-9]+)?$ ]]; then
|
||||||
|
echo "ERROR: Invalid registry hostname format: $REGISTRY"
|
||||||
|
echo "Registry must be a valid hostname optionally with port (e.g., 'ghcr.io' or 'registry.example.com:5000')"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate boolean inputs
|
||||||
|
if [[ "$SETUP_COSIGN" != "true" && "$SETUP_COSIGN" != "false" ]]; then
|
||||||
|
echo "ERROR: setup_cosign must be 'true' or 'false', got: $SETUP_COSIGN"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_LOGIN_ON_PR" != "true" && "$SKIP_LOGIN_ON_PR" != "false" ]]; then
|
||||||
|
echo "ERROR: skip_login_on_pr must be 'true' or 'false', got: $SKIP_LOGIN_ON_PR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SUCCESS: Input validation passed"
|
||||||
|
|
||||||
|
- name: Set up Depot CLI
|
||||||
|
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||||
|
|
||||||
|
- name: Install cosign
|
||||||
|
# Install cosign when requested AND when we might actually sign images
|
||||||
|
# (i.e., non-PR contexts or when we login on PRs)
|
||||||
|
if: ${{ inputs.setup_cosign == 'true' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
|
||||||
|
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||||
|
|
||||||
|
- name: Log into registry
|
||||||
|
if: ${{ inputs.registry != '' && (inputs.skip_login_on_pr == 'false' || github.event_name != 'pull_request') }}
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: ${{ inputs.registry }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ github.token }}
|
||||||
|
|
||||||
|
- name: Verify setup completion
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Verify Depot CLI is available
|
||||||
|
if ! command -v depot >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Depot CLI not found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify cosign if it should be installed (same conditions as install step)
|
||||||
|
if [[ "${{ inputs.setup_cosign }}" == "true" ]] && [[ "${{ inputs.skip_login_on_pr }}" == "false" || "${{ github.event_name }}" != "pull_request" ]]; then
|
||||||
|
if ! command -v cosign >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: Cosign not found in PATH despite being requested"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SUCCESS: Docker build setup completed successfully"
|
||||||
192
.github/actions/resolve-docker-version/action.yml
vendored
Normal file
192
.github/actions/resolve-docker-version/action.yml
vendored
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
name: Resolve Docker Version
|
||||||
|
description: |
|
||||||
|
Resolves and validates Docker-compatible SemVer versions for container builds with comprehensive security.
|
||||||
|
|
||||||
|
Security Features:
|
||||||
|
- Command injection protection
|
||||||
|
- Input sanitization and validation
|
||||||
|
- Docker tag character restrictions
|
||||||
|
- Length limits and boundary checks
|
||||||
|
- Safe branch name handling
|
||||||
|
|
||||||
|
Supports multiple modes: release, manual override, branch auto-detection, and experimental timestamped versions.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Explicit version (SemVer only, e.g., 1.2.3-beta). If provided, this version is used directly. If empty, version is auto-generated from branch name."
|
||||||
|
required: false
|
||||||
|
current_branch:
|
||||||
|
description: "Current branch name for auto-detection"
|
||||||
|
required: true
|
||||||
|
experimental_mode:
|
||||||
|
description: "Enable experimental mode with timestamp-based versions"
|
||||||
|
required: false
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
version:
|
||||||
|
description: "Resolved Docker-compatible SemVer version"
|
||||||
|
value: ${{ steps.resolve.outputs.version }}
|
||||||
|
source:
|
||||||
|
description: "Source of version (release|override|branch)"
|
||||||
|
value: ${{ steps.resolve.outputs.source }}
|
||||||
|
normalized:
|
||||||
|
description: "Whether the version was normalized (true/false)"
|
||||||
|
value: ${{ steps.resolve.outputs.normalized }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Resolve and validate Docker version
|
||||||
|
id: resolve
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
EXPLICIT_VERSION: ${{ inputs.version }}
|
||||||
|
CURRENT_BRANCH: ${{ inputs.current_branch }}
|
||||||
|
EXPERIMENTAL_MODE: ${{ inputs.experimental_mode }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Function to validate SemVer format (Docker-compatible, no '+' build metadata)
|
||||||
|
validate_semver() {
|
||||||
|
local version="$1"
|
||||||
|
local context="$2"
|
||||||
|
|
||||||
|
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||||
|
echo "ERROR: Invalid $context format. Must be semver without build metadata (e.g., 1.2.3, 1.2.3-alpha)"
|
||||||
|
echo "Provided: $version"
|
||||||
|
echo "Note: Docker tags cannot contain '+' characters. Use prerelease identifiers instead."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to generate branch-based version
|
||||||
|
generate_branch_version() {
|
||||||
|
local branch="$1"
|
||||||
|
local use_timestamp="${2:-true}"
|
||||||
|
local timestamp
|
||||||
|
|
||||||
|
if [[ "$use_timestamp" == "true" ]]; then
|
||||||
|
timestamp=$(date +%s)
|
||||||
|
else
|
||||||
|
timestamp=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sanitize branch name for Docker compatibility
|
||||||
|
local sanitized_branch=$(echo "$branch" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||||
|
|
||||||
|
# Additional safety: truncate if too long (reserve space for prefix and timestamp)
|
||||||
|
if (( ${#sanitized_branch} > 80 )); then
|
||||||
|
sanitized_branch="${sanitized_branch:0:80}"
|
||||||
|
echo "INFO: Branch name truncated for Docker compatibility" >&2
|
||||||
|
fi
|
||||||
|
local version
|
||||||
|
|
||||||
|
# Generate version based on branch name (unified approach)
|
||||||
|
# All branches get alpha versions with sanitized branch name
|
||||||
|
if [[ -n "$timestamp" ]]; then
|
||||||
|
version="0.0.0-alpha-$sanitized_branch-$timestamp"
|
||||||
|
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
|
||||||
|
else
|
||||||
|
version="0.0.0-alpha-$sanitized_branch"
|
||||||
|
echo "INFO: Branch '$branch' detected - alpha version: $version" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$version"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Input validation and sanitization
|
||||||
|
if [[ -z "$CURRENT_BRANCH" ]]; then
|
||||||
|
echo "ERROR: current_branch input is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Security: Validate inputs to prevent command injection
|
||||||
|
# Use grep to check for dangerous characters (more reliable than bash regex)
|
||||||
|
validate_input() {
|
||||||
|
local input="$1"
|
||||||
|
local name="$2"
|
||||||
|
|
||||||
|
# Check for dangerous characters using grep
|
||||||
|
if echo "$input" | grep -q '[;|&`$(){}\\[:space:]]'; then
|
||||||
|
echo "ERROR: $name contains potentially dangerous characters: $input"
|
||||||
|
echo "Input should only contain letters, numbers, hyphens, underscores, dots, and forward slashes"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate current branch
|
||||||
|
if ! validate_input "$CURRENT_BRANCH" "Branch name"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate explicit version if provided
|
||||||
|
if [[ -n "$EXPLICIT_VERSION" ]] && ! validate_input "$EXPLICIT_VERSION" "Explicit version"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Main resolution logic (ultra-simplified)
|
||||||
|
NORMALIZED="false"
|
||||||
|
|
||||||
|
if [[ -n "$EXPLICIT_VERSION" ]]; then
|
||||||
|
# Use provided explicit version (from either workflow_call or manual input)
|
||||||
|
validate_semver "$EXPLICIT_VERSION" "explicit version"
|
||||||
|
|
||||||
|
# Normalize to lowercase for Docker/ECR compatibility
|
||||||
|
RESOLVED_VERSION="${EXPLICIT_VERSION,,}"
|
||||||
|
if [[ "$EXPLICIT_VERSION" != "$RESOLVED_VERSION" ]]; then
|
||||||
|
NORMALIZED="true"
|
||||||
|
echo "INFO: Original version contained uppercase characters, normalized: $EXPLICIT_VERSION -> $RESOLVED_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
SOURCE="explicit"
|
||||||
|
echo "INFO: Using explicit version: $RESOLVED_VERSION"
|
||||||
|
|
||||||
|
else
|
||||||
|
# Auto-generate version from branch name
|
||||||
|
if [[ "$EXPERIMENTAL_MODE" == "true" ]]; then
|
||||||
|
# Use timestamped version generation
|
||||||
|
echo "INFO: Experimental mode: generating timestamped version from branch: $CURRENT_BRANCH"
|
||||||
|
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "true")
|
||||||
|
SOURCE="experimental"
|
||||||
|
else
|
||||||
|
# Standard branch version (no timestamp)
|
||||||
|
echo "INFO: Auto-detecting version from branch: $CURRENT_BRANCH"
|
||||||
|
RESOLVED_VERSION=$(generate_branch_version "$CURRENT_BRANCH" "false")
|
||||||
|
SOURCE="branch"
|
||||||
|
fi
|
||||||
|
echo "Generated version: $RESOLVED_VERSION"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Final validation - ensure result is valid Docker tag
|
||||||
|
if [[ -z "$RESOLVED_VERSION" ]]; then
|
||||||
|
echo "ERROR: Failed to resolve version"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if (( ${#RESOLVED_VERSION} > 128 )); then
|
||||||
|
echo "ERROR: Version must be at most 128 characters (Docker limitation)"
|
||||||
|
echo "Generated version: $RESOLVED_VERSION (${#RESOLVED_VERSION} chars)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$RESOLVED_VERSION" =~ ^[a-z0-9._-]+$ ]]; then
|
||||||
|
echo "ERROR: Version contains invalid characters for Docker tags"
|
||||||
|
echo "Version: $RESOLVED_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$RESOLVED_VERSION" =~ ^[.-] || "$RESOLVED_VERSION" =~ [.-]$ ]]; then
|
||||||
|
echo "ERROR: Version must not start or end with '.' or '-'"
|
||||||
|
echo "Version: $RESOLVED_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output results
|
||||||
|
echo "SUCCESS: Resolved Docker version: $RESOLVED_VERSION (source: $SOURCE)"
|
||||||
|
echo "version=$RESOLVED_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
echo "source=$SOURCE" >> $GITHUB_OUTPUT
|
||||||
|
echo "normalized=$NORMALIZED" >> $GITHUB_OUTPUT
|
||||||
160
.github/actions/update-package-version/action.yml
vendored
Normal file
160
.github/actions/update-package-version/action.yml
vendored
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
name: Update Package Version
|
||||||
|
description: |
|
||||||
|
Safely updates package.json version with comprehensive validation and atomic operations.
|
||||||
|
|
||||||
|
Security Features:
|
||||||
|
- Path traversal protection
|
||||||
|
- SemVer validation with length limits
|
||||||
|
- Atomic file operations with backup/recovery
|
||||||
|
- JSON validation before applying changes
|
||||||
|
|
||||||
|
This action is designed to be secure by default and prevent common attack vectors.
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: "Version to set in package.json (must be valid SemVer)"
|
||||||
|
required: true
|
||||||
|
package_path:
|
||||||
|
description: "Path to package.json file"
|
||||||
|
required: false
|
||||||
|
default: "./apps/web/package.json"
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
updated_version:
|
||||||
|
description: "The version that was actually set in package.json"
|
||||||
|
value: ${{ steps.update.outputs.updated_version }}
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: "composite"
|
||||||
|
steps:
|
||||||
|
- name: Update and verify package.json version
|
||||||
|
id: update
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.version }}
|
||||||
|
PACKAGE_PATH: ${{ inputs.package_path }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validate inputs
|
||||||
|
if [[ -z "$VERSION" ]]; then
|
||||||
|
echo "ERROR: version input is required"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Security: Validate package_path to prevent path traversal attacks
|
||||||
|
# Only allow paths within the workspace and must end with package.json
|
||||||
|
if [[ "$PACKAGE_PATH" =~ \.\./|^/|^~ ]]; then
|
||||||
|
echo "ERROR: Invalid package path - path traversal detected: $PACKAGE_PATH"
|
||||||
|
echo "Package path must be relative to workspace root and cannot contain '../', start with '/', or '~'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$PACKAGE_PATH" =~ package\.json$ ]]; then
|
||||||
|
echo "ERROR: Package path must end with 'package.json': $PACKAGE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve to absolute path within workspace for additional security
|
||||||
|
WORKSPACE_ROOT="${GITHUB_WORKSPACE:-$(pwd)}"
|
||||||
|
|
||||||
|
# Use realpath to resolve both paths and handle symlinks properly
|
||||||
|
WORKSPACE_ROOT=$(realpath "$WORKSPACE_ROOT")
|
||||||
|
RESOLVED_PATH=$(realpath "${WORKSPACE_ROOT}/${PACKAGE_PATH}")
|
||||||
|
|
||||||
|
# Ensure WORKSPACE_ROOT has a trailing slash for proper prefix matching
|
||||||
|
WORKSPACE_ROOT="${WORKSPACE_ROOT}/"
|
||||||
|
|
||||||
|
# Use shell string matching to ensure RESOLVED_PATH is within workspace
|
||||||
|
# This is more secure than regex and handles edge cases properly
|
||||||
|
if [[ "$RESOLVED_PATH" != "$WORKSPACE_ROOT"* ]]; then
|
||||||
|
echo "ERROR: Resolved path is outside workspace: $RESOLVED_PATH"
|
||||||
|
echo "Workspace root: $WORKSPACE_ROOT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$RESOLVED_PATH" ]]; then
|
||||||
|
echo "ERROR: package.json not found at: $RESOLVED_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use resolved path for operations
|
||||||
|
PACKAGE_PATH="$RESOLVED_PATH"
|
||||||
|
|
||||||
|
# Validate SemVer format with additional security checks
|
||||||
|
if [[ ${#VERSION} -gt 128 ]]; then
|
||||||
|
echo "ERROR: Version string too long (${#VERSION} chars, max 128): $VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||||
|
echo "ERROR: Invalid SemVer format: $VERSION"
|
||||||
|
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE]"
|
||||||
|
echo "Only alphanumeric characters, dots, and hyphens allowed in prerelease"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Additional validation: Check for reasonable version component sizes
|
||||||
|
# Extract base version (MAJOR.MINOR.PATCH) without prerelease/build metadata
|
||||||
|
if [[ "$VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]]; then
|
||||||
|
BASE_VERSION="${BASH_REMATCH[1]}"
|
||||||
|
else
|
||||||
|
echo "ERROR: Could not extract base version from: $VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Split version components safely
|
||||||
|
IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION"
|
||||||
|
|
||||||
|
# Validate component sizes (should have exactly 3 parts due to regex above)
|
||||||
|
if (( ${VERSION_PARTS[0]} > 999 || ${VERSION_PARTS[1]} > 999 || ${VERSION_PARTS[2]} > 999 )); then
|
||||||
|
echo "ERROR: Version components too large (max 999 each): $VERSION"
|
||||||
|
echo "Components: ${VERSION_PARTS[0]}.${VERSION_PARTS[1]}.${VERSION_PARTS[2]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Updating package.json version to: $VERSION"
|
||||||
|
|
||||||
|
# Create backup for atomic operations
|
||||||
|
BACKUP_PATH="${PACKAGE_PATH}.backup.$$"
|
||||||
|
cp "$PACKAGE_PATH" "$BACKUP_PATH"
|
||||||
|
|
||||||
|
# Use jq to safely update the version field with error handling
|
||||||
|
if ! jq --arg version "$VERSION" '.version = $version' "$PACKAGE_PATH" > "${PACKAGE_PATH}.tmp"; then
|
||||||
|
echo "ERROR: jq failed to process package.json"
|
||||||
|
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate the generated JSON before applying changes
|
||||||
|
if ! jq empty "${PACKAGE_PATH}.tmp" 2>/dev/null; then
|
||||||
|
echo "ERROR: Generated invalid JSON"
|
||||||
|
rm -f "${PACKAGE_PATH}.tmp" "$BACKUP_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Atomic move operation
|
||||||
|
if ! mv "${PACKAGE_PATH}.tmp" "$PACKAGE_PATH"; then
|
||||||
|
echo "ERROR: Failed to update package.json"
|
||||||
|
# Restore backup
|
||||||
|
mv "$BACKUP_PATH" "$PACKAGE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify the update was successful
|
||||||
|
UPDATED_VERSION=$(jq -r '.version' "$PACKAGE_PATH" 2>/dev/null)
|
||||||
|
|
||||||
|
if [[ "$UPDATED_VERSION" != "$VERSION" ]]; then
|
||||||
|
echo "ERROR: Version update failed!"
|
||||||
|
echo "Expected: $VERSION"
|
||||||
|
echo "Actual: $UPDATED_VERSION"
|
||||||
|
# Restore backup
|
||||||
|
mv "$BACKUP_PATH" "$PACKAGE_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up backup on success
|
||||||
|
rm -f "$BACKUP_PATH"
|
||||||
|
|
||||||
|
echo "SUCCESS: Updated package.json version to: $UPDATED_VERSION"
|
||||||
|
echo "updated_version=$UPDATED_VERSION" >> $GITHUB_OUTPUT
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
94
.github/workflows/build-and-push-ecr.yml
vendored
Normal file
94
.github/workflows/build-and-push-ecr.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
name: Build Cloud Deployment Images
|
||||||
|
|
||||||
|
# This workflow builds Formbricks Docker images for ECR deployment:
|
||||||
|
# - workflow_call: Used by releases with explicit SemVer versions
|
||||||
|
# - workflow_dispatch: Auto-detects version from current branch or uses override
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version_override:
|
||||||
|
description: "Override version (SemVer only, e.g., 1.2.3). Leave empty to auto-detect from branch."
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
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
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
image_tag:
|
||||||
|
description: "Image tag to push (required for workflow_call)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
IS_PRERELEASE:
|
||||||
|
description: "Whether this is a prerelease (auto-tags for staging/production)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
MAKE_LATEST:
|
||||||
|
description: "Whether to tag for production (from GitHub release 'Set as the latest release' option)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
outputs:
|
||||||
|
IMAGE_TAG:
|
||||||
|
description: "Normalized image tag used for the build"
|
||||||
|
value: ${{ jobs.build-and-push.outputs.IMAGE_TAG }}
|
||||||
|
TAGS:
|
||||||
|
description: "Newline-separated list of ECR tags pushed"
|
||||||
|
value: ${{ jobs.build-and-push.outputs.TAGS }}
|
||||||
|
|
||||||
|
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 }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
name: Build and Push
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
outputs:
|
||||||
|
IMAGE_TAG: ${{ steps.build.outputs.image_tag }}
|
||||||
|
TAGS: ${{ steps.build.outputs.registry_tags }}
|
||||||
|
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: Build and push cloud deployment image
|
||||||
|
id: build
|
||||||
|
uses: ./.github/actions/build-and-push-docker
|
||||||
|
with:
|
||||||
|
registry_type: "ecr"
|
||||||
|
ecr_registry: ${{ env.ECR_REGISTRY }}
|
||||||
|
ecr_repository: ${{ env.ECR_REPOSITORY }}
|
||||||
|
ecr_region: ${{ env.ECR_REGION }}
|
||||||
|
aws_role_arn: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||||
|
version: ${{ inputs.version_override || inputs.image_tag }}
|
||||||
|
deploy_production: ${{ inputs.deploy_production }}
|
||||||
|
deploy_staging: ${{ inputs.deploy_staging }}
|
||||||
|
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||||
|
make_latest: ${{ inputs.MAKE_LATEST }}
|
||||||
|
env:
|
||||||
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
|
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
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
|
||||||
packages: write
|
packages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
27
.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
|
|
||||||
37
.github/workflows/deploy-formbricks-cloud.yml
vendored
37
.github/workflows/deploy-formbricks-cloud.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
REPOSITORY:
|
REPOSITORY:
|
||||||
@@ -17,8 +17,8 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
type: choice
|
type: choice
|
||||||
options:
|
options:
|
||||||
- stage
|
- staging
|
||||||
- prod
|
- production
|
||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
@@ -37,21 +37,27 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: write
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
helmfile-deploy:
|
helmfile-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@v3
|
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
tags: tag:github
|
tags: tag:github
|
||||||
|
args: --accept-routes
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
- name: Configure AWS Credentials
|
||||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||||
@@ -65,9 +71,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@v2
|
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
||||||
name: Deploy Formbricks Cloud Prod
|
name: Deploy Formbricks Cloud Production
|
||||||
if: inputs.ENVIRONMENT == 'prod'
|
if: inputs.ENVIRONMENT == 'production'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -83,9 +89,9 @@ jobs:
|
|||||||
helmfile-auto-init: "false"
|
helmfile-auto-init: "false"
|
||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@v2
|
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
||||||
name: Deploy Formbricks Cloud Stage
|
name: Deploy Formbricks Cloud Staging
|
||||||
if: inputs.ENVIRONMENT == 'stage'
|
if: inputs.ENVIRONMENT == 'staging'
|
||||||
env:
|
env:
|
||||||
VERSION: ${{ inputs.VERSION }}
|
VERSION: ${{ inputs.VERSION }}
|
||||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
@@ -101,19 +107,20 @@ jobs:
|
|||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- name: Purge Cloudflare Cache
|
- name: Purge Cloudflare Cache
|
||||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||||
env:
|
env:
|
||||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
|
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
||||||
run: |
|
run: |
|
||||||
# Set hostname based on environment
|
# Set hostname based on environment
|
||||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
if [[ "$ENVIRONMENT" == "production" ]]; then
|
||||||
PURGE_HOST="app.formbricks.com"
|
PURGE_HOST="app.formbricks.com"
|
||||||
else
|
else
|
||||||
PURGE_HOST="stage.app.formbricks.com"
|
PURGE_HOST="stage.app.formbricks.com"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ 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
|
# Prepare JSON payload for selective cache purge
|
||||||
json_payload=$(cat << EOF
|
json_payload=$(cat << EOF
|
||||||
|
|||||||
159
.github/workflows/docker-build-validation.yml
vendored
159
.github/workflows/docker-build-validation.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
name: Validate Docker Build
|
name: Validate Docker Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
# Add PostgreSQL service container
|
# Add PostgreSQL and Redis service containers
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||||
env:
|
env:
|
||||||
POSTGRES_USER: test
|
POSTGRES_USER: test
|
||||||
POSTGRES_PASSWORD: test
|
POSTGRES_PASSWORD: test
|
||||||
@@ -38,43 +38,98 @@ jobs:
|
|||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
|
||||||
- name: Build Docker Image
|
- 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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./apps/web/Dockerfile
|
file: ./apps/web/Dockerfile
|
||||||
push: false
|
push: false
|
||||||
load: true
|
load: true
|
||||||
tags: formbricks-test:${{ github.sha }}
|
tags: formbricks-test:${{ env.GITHUB_SHA }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
secrets: |
|
secrets: |
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
redis_url=redis://localhost:6379
|
||||||
|
|
||||||
- name: Verify PostgreSQL Connection
|
- name: Verify and Initialize PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
echo "Verifying PostgreSQL connection..."
|
echo "Verifying PostgreSQL connection..."
|
||||||
# Install PostgreSQL client to test connection
|
# Install PostgreSQL client to test connection
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
# Test connection using psql
|
# Test connection using psql with timeout and proper error handling
|
||||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
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
|
# Show network configuration
|
||||||
echo "Network configuration:"
|
echo "Network configuration:"
|
||||||
ip addr show
|
|
||||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||||
|
|
||||||
|
- name: Verify Redis/Valkey Connection
|
||||||
|
run: |
|
||||||
|
echo "Verifying Redis/Valkey connection..."
|
||||||
|
# Install Redis client to test connection
|
||||||
|
sudo apt-get update && sudo apt-get install -y redis-tools
|
||||||
|
|
||||||
|
# Test connection using redis-cli with timeout and proper error handling
|
||||||
|
echo "Testing Redis connection with 30 second timeout..."
|
||||||
|
if timeout 30 bash -c 'until redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; do
|
||||||
|
echo "Waiting for Redis to be ready..."
|
||||||
|
sleep 2
|
||||||
|
done'; then
|
||||||
|
echo "✅ Redis connection successful"
|
||||||
|
redis-cli -h localhost -p 6379 info server | head -5
|
||||||
|
else
|
||||||
|
echo "❌ Redis connection failed after 30 seconds"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Show network configuration for Redis
|
||||||
|
echo "Redis network configuration:"
|
||||||
|
netstat -tulpn | grep 6379 || echo "No process listening on port 6379"
|
||||||
|
|
||||||
- name: Test Docker Image with Health Check
|
- name: Test Docker Image with Health Check
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_SHA: ${{ github.sha }}
|
||||||
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
run: |
|
run: |
|
||||||
echo "🧪 Testing if the Docker image starts correctly..."
|
echo "🧪 Testing if the Docker image starts correctly..."
|
||||||
|
|
||||||
@@ -86,29 +141,13 @@ jobs:
|
|||||||
$DOCKER_RUN_ARGS \
|
$DOCKER_RUN_ARGS \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||||
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||||
-d formbricks-test:${{ github.sha }}
|
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||||
|
-d "formbricks-test:$GITHUB_SHA"
|
||||||
|
|
||||||
# Give it more time to start up
|
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||||
echo "Waiting 45 seconds for application to start..."
|
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
||||||
sleep 45
|
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
|
||||||
|
|
||||||
# Check if the container is running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
|
||||||
echo "❌ Container failed to start properly!"
|
|
||||||
docker logs formbricks-test
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ Container started successfully!"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Try connecting to PostgreSQL from inside the container
|
|
||||||
echo "Testing PostgreSQL connection from inside container..."
|
|
||||||
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
|
||||||
|
|
||||||
# Try to access the health endpoint
|
|
||||||
echo "🏥 Testing /health endpoint..."
|
|
||||||
MAX_RETRIES=10
|
|
||||||
RETRY_COUNT=0
|
RETRY_COUNT=0
|
||||||
HEALTH_CHECK_SUCCESS=false
|
HEALTH_CHECK_SUCCESS=false
|
||||||
|
|
||||||
@@ -116,38 +155,32 @@ jobs:
|
|||||||
|
|
||||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
|
||||||
|
# Check if container is still running
|
||||||
# Show container logs before each attempt to help debugging
|
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
|
||||||
if [ $RETRY_COUNT -gt 1 ]; then
|
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
|
||||||
echo "📋 Current container logs:"
|
echo "📋 Container logs:"
|
||||||
docker logs --tail 20 formbricks-test
|
docker logs formbricks-test
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Get detailed curl output for debugging
|
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
|
||||||
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
|
||||||
CURL_EXIT_CODE=$?
|
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
|
||||||
|
echo "📋 Recent container logs:"
|
||||||
echo "Curl exit code: $CURL_EXIT_CODE"
|
docker logs --tail 10 formbricks-test
|
||||||
echo "Curl output: $HTTP_OUTPUT"
|
|
||||||
|
|
||||||
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
|
||||||
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
|
||||||
echo "Status code detected: $STATUS_CODE"
|
|
||||||
|
|
||||||
if [ "$STATUS_CODE" = "200" ]; then
|
|
||||||
echo "✅ Health check successful!"
|
|
||||||
HEALTH_CHECK_SUCCESS=true
|
|
||||||
break
|
|
||||||
else
|
|
||||||
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Waiting 15 seconds before next attempt..."
|
# Try health endpoint with shorter timeout for faster polling
|
||||||
sleep 15
|
# 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
|
done
|
||||||
|
|
||||||
# Show full container logs for debugging
|
# Show full container logs for debugging
|
||||||
@@ -160,7 +193,7 @@ jobs:
|
|||||||
|
|
||||||
# Exit with failure if health check did not succeed
|
# Exit with failure if health check did not succeed
|
||||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||||
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
||||||
101
.github/workflows/e2e.yml
vendored
101
.github/workflows/e2e.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
timeout-minutes: 60
|
timeout-minutes: 60
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg17
|
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||||
env:
|
env:
|
||||||
POSTGRES_DB: postgres
|
POSTGRES_DB: postgres
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
@@ -41,27 +41,23 @@ jobs:
|
|||||||
ports:
|
ports:
|
||||||
- 5432:5432
|
- 5432:5432
|
||||||
options: >-
|
options: >-
|
||||||
--health-cmd="pg_isready -U testuser"
|
--health-cmd="pg_isready -U postgres"
|
||||||
--health-interval=10s
|
--health-interval=10s
|
||||||
--health-timeout=5s
|
--health-timeout=5s
|
||||||
--health-retries=5
|
--health-retries=5
|
||||||
valkey:
|
valkey:
|
||||||
image: valkey/valkey:8.1.1
|
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||||
ports:
|
ports:
|
||||||
- 6379:6379
|
- 6379:6379
|
||||||
options: >-
|
|
||||||
--entrypoint "valkey-server"
|
|
||||||
--health-cmd="valkey-cli ping"
|
|
||||||
--health-interval=10s
|
|
||||||
--health-timeout=5s
|
|
||||||
--health-retries=5
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: allow
|
egress-policy: audit
|
||||||
allowed-endpoints: |
|
allowed-endpoints: |
|
||||||
ee.formbricks.com:443
|
ee.formbricks.com:443
|
||||||
|
registry-1.docker.io:443
|
||||||
|
docker.io:443
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
- uses: ./.github/actions/dangerous-git-checkout
|
||||||
@@ -89,10 +85,72 @@ jobs:
|
|||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||||
|
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||||
echo "" >> .env
|
echo "" >> .env
|
||||||
echo "E2E_TESTING=1" >> .env
|
echo "E2E_TESTING=1" >> .env
|
||||||
|
echo "S3_REGION=us-east-1" >> .env
|
||||||
|
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||||
|
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||||
|
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||||
|
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||||
|
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
|
- name: Install MinIO client (mc)
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
|
||||||
|
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
|
||||||
|
MC_BIN="mc.${MC_VERSION}"
|
||||||
|
MC_SUM="${MC_BIN}.sha256sum"
|
||||||
|
|
||||||
|
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
|
||||||
|
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
|
||||||
|
|
||||||
|
sha256sum -c "${MC_SUM}"
|
||||||
|
|
||||||
|
chmod +x "${MC_BIN}"
|
||||||
|
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||||
|
|
||||||
|
- name: Start MinIO Server
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Start MinIO server in background
|
||||||
|
docker run -d \
|
||||||
|
--name minio-server \
|
||||||
|
-p 9000:9000 \
|
||||||
|
-p 9001:9001 \
|
||||||
|
-e MINIO_ROOT_USER=devminio \
|
||||||
|
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||||
|
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||||
|
server /data --console-address :9001
|
||||||
|
|
||||||
|
echo "MinIO server started"
|
||||||
|
|
||||||
|
- name: Wait for MinIO and create S3 bucket
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Waiting for MinIO to be ready..."
|
||||||
|
ready=0
|
||||||
|
for i in {1..60}; do
|
||||||
|
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
|
||||||
|
echo "MinIO is up after ${i} seconds"
|
||||||
|
ready=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$ready" -ne 1 ]; then
|
||||||
|
echo "::error::MinIO did not become ready within 60 seconds"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mc alias set local http://localhost:9000 devminio devminio123
|
||||||
|
mc mb --ignore-existing local/formbricks-e2e
|
||||||
|
|
||||||
- name: Build App
|
- name: Build App
|
||||||
run: |
|
run: |
|
||||||
pnpm build --filter=@formbricks/web...
|
pnpm build --filter=@formbricks/web...
|
||||||
@@ -102,6 +160,18 @@ jobs:
|
|||||||
# pnpm prisma migrate deploy
|
# pnpm prisma migrate deploy
|
||||||
pnpm db:migrate:dev
|
pnpm db:migrate:dev
|
||||||
|
|
||||||
|
- name: Run Rate Limiter Load Tests
|
||||||
|
run: |
|
||||||
|
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||||
|
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Run Cache Integration Tests
|
||||||
|
run: |
|
||||||
|
echo "Running cache integration tests with Redis/Valkey..."
|
||||||
|
cd packages/cache && pnpm vitest run src/cache-integration.test.ts
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Check for Enterprise License
|
- name: Check for Enterprise License
|
||||||
run: |
|
run: |
|
||||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||||
@@ -111,6 +181,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
echo "License key length: ${#LICENSE_KEY}"
|
echo "License key length: ${#LICENSE_KEY}"
|
||||||
|
|
||||||
|
- name: Disable rate limiting for E2E tests
|
||||||
|
run: |
|
||||||
|
echo "RATE_LIMITING_DISABLED=1" >> .env
|
||||||
|
echo "Rate limiting disabled for E2E tests"
|
||||||
|
shell: bash
|
||||||
|
|
||||||
- name: Run App
|
- name: Run App
|
||||||
run: |
|
run: |
|
||||||
echo "Starting app with enterprise license..."
|
echo "Starting app with enterprise license..."
|
||||||
@@ -152,11 +228,14 @@ jobs:
|
|||||||
if: env.AZURE_ENABLED == 'true'
|
if: env.AZURE_ENABLED == 'true'
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
pnpm test-e2e:azure
|
pnpm test-e2e:azure
|
||||||
|
|
||||||
- name: Run E2E Tests (Local)
|
- name: Run E2E Tests (Local)
|
||||||
if: env.AZURE_ENABLED == 'false'
|
if: env.AZURE_ENABLED == 'false'
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
pnpm test:e2e
|
pnpm test:e2e
|
||||||
|
|
||||||
|
|||||||
157
.github/workflows/formbricks-release.yml
vendored
157
.github/workflows/formbricks-release.yml
vendored
@@ -1,34 +1,157 @@
|
|||||||
name: Build, release & deploy Formbricks images
|
name: Build, release & deploy Formbricks images
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
release:
|
||||||
push:
|
types: [published]
|
||||||
tags:
|
|
||||||
- "v*"
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
check-latest-release:
|
||||||
name: Build & release stable docker image
|
name: Check if this is the latest release
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
is_latest: ${{ steps.compare_tags.outputs.is_latest }}
|
||||||
|
# This job determines if the current release was marked as "Set as the latest release"
|
||||||
|
# by comparing it with the latest release from GitHub API
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Get latest release tag from API
|
||||||
|
id: get_latest_release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Get the latest release tag from GitHub API with error handling
|
||||||
|
echo "Fetching latest release from GitHub API..."
|
||||||
|
|
||||||
|
# Use curl with error handling - API returns 404 if no releases exist
|
||||||
|
http_code=$(curl -s -w "%{http_code}" -H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
|
"https://api.github.com/repos/${REPO}/releases/latest" -o /tmp/latest_release.json)
|
||||||
|
|
||||||
|
if [[ "$http_code" == "404" ]]; then
|
||||||
|
echo "⚠️ No previous releases found (404). This appears to be the first release."
|
||||||
|
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "$http_code" == "200" ]]; then
|
||||||
|
latest_release=$(jq -r .tag_name /tmp/latest_release.json)
|
||||||
|
if [[ "$latest_release" == "null" || -z "$latest_release" ]]; then
|
||||||
|
echo "⚠️ API returned null/empty tag_name. Treating as first release."
|
||||||
|
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Latest release from API: ${latest_release}"
|
||||||
|
echo "latest_release=${latest_release}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ GitHub API error (HTTP ${http_code}). Treating as first release."
|
||||||
|
echo "latest_release=" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Current release tag: ${{ github.event.release.tag_name }}"
|
||||||
|
|
||||||
|
- name: Compare release tags
|
||||||
|
id: compare_tags
|
||||||
|
env:
|
||||||
|
CURRENT_TAG: ${{ github.event.release.tag_name }}
|
||||||
|
LATEST_TAG: ${{ steps.get_latest_release.outputs.latest_release }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Handle first release case (no previous releases)
|
||||||
|
if [[ -z "${LATEST_TAG}" ]]; then
|
||||||
|
echo "🎉 This is the first release (${CURRENT_TAG}) - treating as latest"
|
||||||
|
echo "is_latest=true" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "${CURRENT_TAG}" == "${LATEST_TAG}" ]]; then
|
||||||
|
echo "✅ This release (${CURRENT_TAG}) is marked as the latest release"
|
||||||
|
echo "is_latest=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "ℹ️ This release (${CURRENT_TAG}) is not the latest release (latest: ${LATEST_TAG})"
|
||||||
|
echo "is_latest=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
docker-build-community:
|
||||||
|
name: Build & release community docker image
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
uses: ./.github/workflows/release-docker-github.yml
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
needs:
|
||||||
|
- check-latest-release
|
||||||
|
with:
|
||||||
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|
||||||
|
docker-build-cloud:
|
||||||
|
name: Build & push Formbricks Cloud to ECR
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
uses: ./.github/workflows/build-and-push-ecr.yml
|
||||||
|
secrets: inherit
|
||||||
|
with:
|
||||||
|
image_tag: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
|
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||||
|
MAKE_LATEST: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
needs:
|
||||||
|
- check-latest-release
|
||||||
|
- docker-build-community
|
||||||
|
|
||||||
helm-chart-release:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
uses: ./.github/workflows/release-helm-chart.yml
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
needs:
|
needs:
|
||||||
- docker-build
|
- docker-build-community
|
||||||
with:
|
with:
|
||||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||||
|
|
||||||
deploy-formbricks-cloud:
|
verify-cloud-build:
|
||||||
name: Deploy Helm Chart to Formbricks Cloud
|
name: Verify Cloud Build Outputs
|
||||||
secrets: inherit
|
runs-on: ubuntu-latest
|
||||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
timeout-minutes: 5 # Simple verification should be quick
|
||||||
needs:
|
needs:
|
||||||
- docker-build
|
- docker-build-cloud
|
||||||
- helm-chart-release
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Display ECR build outputs
|
||||||
|
env:
|
||||||
|
IMAGE_TAG: ${{ needs.docker-build-cloud.outputs.IMAGE_TAG }}
|
||||||
|
TAGS: ${{ needs.docker-build-cloud.outputs.TAGS }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "✅ ECR Build Completed Successfully"
|
||||||
|
echo "Image Tag: ${IMAGE_TAG}"
|
||||||
|
echo "ECR Tags:"
|
||||||
|
printf '%s\n' "${TAGS}"
|
||||||
|
|
||||||
|
move-stable-tag:
|
||||||
|
name: Move stable tag to release
|
||||||
|
permissions:
|
||||||
|
contents: write # Required for tag push operations in called workflow
|
||||||
|
uses: ./.github/workflows/move-stable-tag.yml
|
||||||
|
needs:
|
||||||
|
- check-latest-release
|
||||||
|
- docker-build-community # Ensure release is successful first
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
release_tag: ${{ github.event.release.tag_name }}
|
||||||
ENVIRONMENT: "prod"
|
commit_sha: ${{ github.sha }}
|
||||||
|
is_prerelease: ${{ github.event.release.prerelease }}
|
||||||
|
make_latest: ${{ needs.check-latest-release.outputs.is_latest }}
|
||||||
|
|||||||
101
.github/workflows/move-stable-tag.yml
vendored
Normal file
101
.github/workflows/move-stable-tag.yml
vendored
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
name: Move Stable Tag
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
release_tag:
|
||||||
|
description: "The release tag name (e.g., 1.2.3)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
commit_sha:
|
||||||
|
description: "The commit SHA to point the stable tag to"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
is_prerelease:
|
||||||
|
description: "Whether this is a prerelease (stable tag won't be moved for prereleases)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
make_latest:
|
||||||
|
description: "Whether to move stable tag (from GitHub release 'Set as the latest release' option)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# Prevent concurrent stable tag operations to avoid race conditions
|
||||||
|
concurrency:
|
||||||
|
group: move-stable-tag-${{ github.repository }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
move-stable-tag:
|
||||||
|
name: Move stable tag to release
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10 # Prevent hung git operations
|
||||||
|
permissions:
|
||||||
|
contents: write # Required to push tags
|
||||||
|
# Only move stable tag for non-prerelease versions AND when make_latest is true
|
||||||
|
if: ${{ !inputs.is_prerelease && inputs.make_latest }}
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Full history needed for tag operations
|
||||||
|
|
||||||
|
- name: Validate inputs
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Validate release tag format
|
||||||
|
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||||
|
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
|
||||||
|
echo "Provided: $RELEASE_TAG"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Validate commit SHA format (40 character hex)
|
||||||
|
if [[ ! "$COMMIT_SHA" =~ ^[a-f0-9]{40}$ ]]; then
|
||||||
|
echo "❌ Error: Invalid commit SHA format. Expected 40 character hex string"
|
||||||
|
echo "Provided: $COMMIT_SHA"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Input validation passed"
|
||||||
|
echo "Release tag: $RELEASE_TAG"
|
||||||
|
echo "Commit SHA: $COMMIT_SHA"
|
||||||
|
|
||||||
|
- name: Move stable tag
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ inputs.release_tag }}
|
||||||
|
COMMIT_SHA: ${{ inputs.commit_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configure git
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Verify the commit exists
|
||||||
|
if ! git cat-file -e "$COMMIT_SHA"; then
|
||||||
|
echo "❌ Error: Commit $COMMIT_SHA does not exist in this repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Move stable tag to the release commit
|
||||||
|
echo "📌 Moving stable tag to commit: $COMMIT_SHA (release: $RELEASE_TAG)"
|
||||||
|
git tag -f stable "$COMMIT_SHA"
|
||||||
|
git push origin stable --force
|
||||||
|
|
||||||
|
echo "✅ Successfully moved stable tag to release $RELEASE_TAG"
|
||||||
|
echo "🔗 Stable tag now points to: https://github.com/${{ github.repository }}/commit/$COMMIT_SHA"
|
||||||
165
.github/workflows/pr-size-check.yml
vendored
Normal file
165
.github/workflows/pr-size-check.yml
vendored
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
name: PR Size Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-pr-size:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Check PR size
|
||||||
|
id: check-size
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fetch the base branch
|
||||||
|
git fetch origin "${{ github.base_ref }}"
|
||||||
|
|
||||||
|
# Get diff stats
|
||||||
|
diff_output=$(git diff --numstat "origin/${{ github.base_ref }}"...HEAD)
|
||||||
|
|
||||||
|
# Count lines, excluding:
|
||||||
|
# - Test files (*.test.ts, *.spec.tsx, etc.)
|
||||||
|
# - Locale files (locales/*.json, i18n/*.json)
|
||||||
|
# - Lock files (pnpm-lock.yaml, package-lock.json, yarn.lock)
|
||||||
|
# - Generated files (dist/, coverage/, build/, .next/)
|
||||||
|
# - Storybook stories (*.stories.tsx)
|
||||||
|
|
||||||
|
total_additions=0
|
||||||
|
total_deletions=0
|
||||||
|
counted_files=0
|
||||||
|
excluded_files=0
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r additions deletions file; do
|
||||||
|
# Skip if additions or deletions are "-" (binary files)
|
||||||
|
if [ "$additions" = "-" ] || [ "$deletions" = "-" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if file should be excluded
|
||||||
|
case "$file" in
|
||||||
|
*.test.ts|*.test.tsx|*.spec.ts|*.spec.tsx|*.test.js|*.test.jsx|*.spec.js|*.spec.jsx)
|
||||||
|
excluded_files=$((excluded_files + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*/locales/*.json|*/i18n/*.json)
|
||||||
|
excluded_files=$((excluded_files + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
pnpm-lock.yaml|package-lock.json|yarn.lock)
|
||||||
|
excluded_files=$((excluded_files + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
dist/*|coverage/*|build/*|node_modules/*|test-results/*|playwright-report/*|.next/*|*.tsbuildinfo)
|
||||||
|
excluded_files=$((excluded_files + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
*.stories.ts|*.stories.tsx|*.stories.js|*.stories.jsx)
|
||||||
|
excluded_files=$((excluded_files + 1))
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
total_additions=$((total_additions + additions))
|
||||||
|
total_deletions=$((total_deletions + deletions))
|
||||||
|
counted_files=$((counted_files + 1))
|
||||||
|
done <<EOF
|
||||||
|
${diff_output}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
total_changes=$((total_additions + total_deletions))
|
||||||
|
|
||||||
|
echo "counted_files=${counted_files}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "excluded_files=${excluded_files}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "total_additions=${total_additions}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "total_deletions=${total_deletions}" >> "${GITHUB_OUTPUT}"
|
||||||
|
echo "total_changes=${total_changes}" >> "${GITHUB_OUTPUT}"
|
||||||
|
|
||||||
|
# Set flag if PR is too large (> 800 lines)
|
||||||
|
if [ ${total_changes} -gt 800 ]; then
|
||||||
|
echo "is_too_large=true" >> "${GITHUB_OUTPUT}"
|
||||||
|
else
|
||||||
|
echo "is_too_large=false" >> "${GITHUB_OUTPUT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Comment on PR if too large
|
||||||
|
if: steps.check-size.outputs.is_too_large == 'true'
|
||||||
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
const totalChanges = ${{ steps.check-size.outputs.total_changes }};
|
||||||
|
const countedFiles = ${{ steps.check-size.outputs.counted_files }};
|
||||||
|
const excludedFiles = ${{ steps.check-size.outputs.excluded_files }};
|
||||||
|
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||||
|
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||||
|
|
||||||
|
const body = `## 🚨 PR Size Warning
|
||||||
|
|
||||||
|
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||||
|
|
||||||
|
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||||
|
|
||||||
|
### 💡 Suggestions:
|
||||||
|
- **Split by feature or module** - Break down into logical, independent pieces
|
||||||
|
- **Create a sequence of PRs** - Each building on the previous one
|
||||||
|
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||||
|
|
||||||
|
### 📊 What was counted:
|
||||||
|
- ✅ Source files, stylesheets, configuration files
|
||||||
|
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||||
|
|
||||||
|
### 📚 Guidelines:
|
||||||
|
- **Ideal:** 300-500 lines per PR
|
||||||
|
- **Warning:** 500-800 lines
|
||||||
|
- **Critical:** 800+ lines ⚠️
|
||||||
|
|
||||||
|
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||||
|
|
||||||
|
// Check if we already commented
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
|
||||||
|
const botComment = comments.find(comment =>
|
||||||
|
comment.user.type === 'Bot' &&
|
||||||
|
comment.body.includes('🚨 PR Size Warning')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (botComment) {
|
||||||
|
// Update existing comment
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: botComment.id,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Create new comment
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: body
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
2
.github/workflows/pr.yml
vendored
2
.github/workflows/pr.yml
vendored
@@ -10,8 +10,6 @@ permissions:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
merge_group:
|
merge_group:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|||||||
@@ -1,99 +1,50 @@
|
|||||||
name: Docker Release to Github Experimental
|
name: Build Community Testing Images
|
||||||
|
|
||||||
# This workflow uses actions that are not certified by GitHub.
|
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
|
||||||
# They are provided by a third-party and are governed by
|
# to test fixes and features before official releases. Images are pushed to GHCR with
|
||||||
# separate terms of service, privacy policy, and support
|
# timestamped experimental versions for easy identification and testing.
|
||||||
# documentation.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
env:
|
version_override:
|
||||||
# Use docker.io for Docker Hub if empty
|
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
|
||||||
REGISTRY: ghcr.io
|
required: false
|
||||||
# github.repository as <account>/<repo>
|
type: string
|
||||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-community-testing:
|
||||||
|
name: Build Community Testing Image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
timeout-minutes: 45
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
# This is used to complete the identity challenge
|
|
||||||
# with sigstore/fulcio when running outside of PRs.
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
|
||||||
# https://github.com/sigstore/cosign-installer
|
|
||||||
- name: Install cosign
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
|
||||||
# https://github.com/docker/login-action
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
fetch-depth: 0
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
# Extract metadata (tags, labels) for Docker
|
- name: Build and push community testing image
|
||||||
# https://github.com/docker/metadata-action
|
uses: ./.github/actions/build-and-push-docker
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
registry_type: "ghcr"
|
||||||
|
ghcr_image_name: "${{ github.repository }}-experimental"
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
experimental_mode: "true"
|
||||||
# https://github.com/docker/build-push-action
|
version: ${{ inputs.version_override }}
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
|
||||||
with:
|
|
||||||
project: tw0fqmsx3c
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
context: .
|
|
||||||
file: ./apps/web/Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
secrets: |
|
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
|
||||||
# transparency data even for private images, pass --force to cosign below.
|
|
||||||
# https://github.com/sigstore/cosign
|
|
||||||
- name: Sign the published Docker image
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
env:
|
||||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
# against the sigstore community Fulcio instance.
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
132
.github/workflows/release-docker-github.yml
vendored
132
.github/workflows/release-docker-github.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Docker Release to Github
|
name: Release Community Docker Images
|
||||||
|
|
||||||
# This workflow uses actions that are not certified by GitHub.
|
# This workflow uses actions that are not certified by GitHub.
|
||||||
# They are provided by a third-party and are governed by
|
# They are provided by a third-party and are governed by
|
||||||
@@ -7,6 +7,17 @@ name: Docker Release to Github
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
IS_PRERELEASE:
|
||||||
|
description: "Whether this is a prerelease (affects latest tag)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
MAKE_LATEST:
|
||||||
|
description: "Whether to tag as latest (from GitHub release 'Set as the latest release' option)"
|
||||||
|
required: false
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
outputs:
|
outputs:
|
||||||
VERSION:
|
VERSION:
|
||||||
description: release version
|
description: release version
|
||||||
@@ -17,12 +28,14 @@ env:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
# github.repository as <account>/<repo>
|
# github.repository as <account>/<repo>
|
||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
@@ -35,82 +48,61 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Get Release Tag
|
- name: Extract release version from tag
|
||||||
id: extract_release_tag
|
id: extract_release_tag
|
||||||
run: |
|
run: |
|
||||||
TAG=${{ github.ref }}
|
set -euo pipefail
|
||||||
TAG=${TAG#refs/tags/v}
|
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
# Extract tag name with fallback logic for different trigger contexts
|
||||||
|
if [[ -n "${RELEASE_TAG:-}" ]]; then
|
||||||
|
TAG="$RELEASE_TAG"
|
||||||
|
echo "Using RELEASE_TAG override: $TAG"
|
||||||
|
elif [[ "$GITHUB_REF_NAME" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]] || [[ "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
echo "Using GITHUB_REF_NAME (looks like tag): $TAG"
|
||||||
|
else
|
||||||
|
# Fallback: extract from GITHUB_REF for direct tag triggers
|
||||||
|
TAG="${GITHUB_REF#refs/tags/}"
|
||||||
|
if [[ -z "$TAG" || "$TAG" == "$GITHUB_REF" ]]; then
|
||||||
|
TAG="$GITHUB_REF_NAME"
|
||||||
|
echo "Using GITHUB_REF_NAME as final fallback: $TAG"
|
||||||
|
else
|
||||||
|
echo "Extracted from GITHUB_REF: $TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Strip v-prefix if present (normalize to clean SemVer)
|
||||||
|
TAG=${TAG#[vV]}
|
||||||
|
|
||||||
|
# Validate SemVer format (supports prereleases like 4.0.0-rc.1)
|
||||||
|
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||||
|
echo "ERROR: Invalid tag format '$TAG'. Expected SemVer (e.g., 1.2.3, 4.0.0-rc.1)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Using version: $TAG"
|
||||||
|
|
||||||
- name: Update package.json version
|
- name: Build and push community release image
|
||||||
run: |
|
id: build
|
||||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
uses: ./.github/actions/build-and-push-docker
|
||||||
cat ./apps/web/package.json | grep version
|
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
|
||||||
|
|
||||||
# Install the cosign tool except on PR
|
|
||||||
# https://github.com/sigstore/cosign-installer
|
|
||||||
- name: Install cosign
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
|
||||||
|
|
||||||
# Login against a Docker registry except on PR
|
|
||||||
# https://github.com/docker/login-action
|
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry_type: "ghcr"
|
||||||
username: ${{ github.actor }}
|
ghcr_image_name: ${{ env.IMAGE_NAME }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
version: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||||
|
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||||
# Extract metadata (tags, labels) for Docker
|
make_latest: ${{ inputs.MAKE_LATEST }}
|
||||||
# https://github.com/docker/metadata-action
|
|
||||||
- name: Extract Docker metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
|
||||||
with:
|
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
|
||||||
# https://github.com/docker/build-push-action
|
|
||||||
- name: Build and push Docker image
|
|
||||||
id: build-and-push
|
|
||||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
|
||||||
with:
|
|
||||||
project: tw0fqmsx3c
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
context: .
|
|
||||||
file: ./apps/web/Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
secrets: |
|
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
|
||||||
# repository is public to avoid leaking data. If you would like to publish
|
|
||||||
# transparency data even for private images, pass --force to cosign below.
|
|
||||||
# https://github.com/sigstore/cosign
|
|
||||||
- name: Sign the published Docker image
|
|
||||||
if: ${{ github.event_name != 'pull_request' }}
|
|
||||||
env:
|
env:
|
||||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAGS: ${{ steps.meta.outputs.tags }}
|
DEPOT_PROJECT_TOKEN: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
DUMMY_DATABASE_URL: ${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
# against the sigstore community Fulcio instance.
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
|||||||
53
.github/workflows/release-helm-chart.yml
vendored
53
.github/workflows/release-helm-chart.yml
vendored
@@ -19,15 +19,30 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Extract release version
|
- name: Validate input version
|
||||||
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
env:
|
||||||
|
INPUT_VERSION: ${{ inputs.VERSION }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Validate input version format (expects clean semver without 'v' prefix)
|
||||||
|
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||||
|
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||||
|
echo "Expected: clean version without 'v' prefix"
|
||||||
|
echo "Provided: $INPUT_VERSION"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Store validated version in environment variable
|
||||||
|
echo "VERSION<<EOF" >> $GITHUB_ENV
|
||||||
|
echo "$INPUT_VERSION" >> $GITHUB_ENV
|
||||||
|
echo "EOF" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||||
@@ -35,20 +50,44 @@ jobs:
|
|||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- 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
|
- name: Install YQ
|
||||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||||
|
|
||||||
- name: Update Chart.yaml with new version
|
- name: Update Chart.yaml with new version
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
set -euo pipefail
|
||||||
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
|
||||||
|
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||||
|
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||||
|
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||||
|
|
||||||
|
echo "✅ Successfully updated Chart.yaml"
|
||||||
|
|
||||||
- name: Package Helm chart
|
- name: Package Helm chart
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Packaging Helm chart version: ${VERSION}"
|
||||||
helm package ./helm-chart
|
helm package ./helm-chart
|
||||||
|
|
||||||
|
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||||
|
|
||||||
- name: Push Helm chart to GitHub Container Registry
|
- name: Push Helm chart to GitHub Container Registry
|
||||||
|
env:
|
||||||
|
VERSION: ${{ env.VERSION }}
|
||||||
run: |
|
run: |
|
||||||
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "Pushing Helm chart to registry: formbricks-${VERSION}.tgz"
|
||||||
|
helm push "formbricks-${VERSION}.tgz" oci://ghcr.io/formbricks/helm-charts
|
||||||
|
|
||||||
|
echo "✅ Successfully pushed Helm chart to registry"
|
||||||
|
|||||||
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 }}
|
${{ 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/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
84
.github/workflows/terraform-plan-and-apply.yml
vendored
84
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,84 +0,0 @@
|
|||||||
name: "Terraform"
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
# TODO: enable it back when migration is completed.
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "infra/terraform/**"
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
|
||||||
- "infra/terraform/**"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
terraform:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Tailscale
|
|
||||||
uses: tailscale/github-action@v3
|
|
||||||
with:
|
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
|
||||||
tags: tag:github
|
|
||||||
|
|
||||||
- name: Configure AWS Credentials
|
|
||||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
|
||||||
with:
|
|
||||||
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
|
||||||
aws-region: "eu-central-1"
|
|
||||||
|
|
||||||
- name: Setup Terraform
|
|
||||||
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
|
||||||
|
|
||||||
- name: Terraform Format
|
|
||||||
id: fmt
|
|
||||||
run: terraform fmt -check -recursive
|
|
||||||
continue-on-error: true
|
|
||||||
working-directory: infra/terraform
|
|
||||||
|
|
||||||
- name: Terraform Init
|
|
||||||
id: init
|
|
||||||
run: terraform init
|
|
||||||
working-directory: infra/terraform
|
|
||||||
|
|
||||||
- name: Terraform Validate
|
|
||||||
id: validate
|
|
||||||
run: terraform validate
|
|
||||||
working-directory: infra/terraform
|
|
||||||
|
|
||||||
- name: Terraform Plan
|
|
||||||
id: plan
|
|
||||||
run: terraform plan -out .planfile
|
|
||||||
working-directory: infra/terraform
|
|
||||||
|
|
||||||
- name: Post PR comment
|
|
||||||
uses: borchero/terraform-plan-comment@434458316f8f24dd073cd2561c436cce41dc8f34 # v2.4.1
|
|
||||||
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
|
||||||
with:
|
|
||||||
token: ${{ github.token }}
|
|
||||||
planfile: .planfile
|
|
||||||
working-directory: "infra/terraform"
|
|
||||||
|
|
||||||
- name: Terraform Apply
|
|
||||||
id: apply
|
|
||||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
|
||||||
run: terraform apply .planfile
|
|
||||||
working-directory: "infra/terraform"
|
|
||||||
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/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||||
|
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: pnpm test
|
run: pnpm test
|
||||||
|
|||||||
10
.github/workflows/tolgee.yml
vendored
10
.github/workflows/tolgee.yml
vendored
@@ -27,10 +27,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Get source branch name
|
- name: Get source branch name
|
||||||
id: branch-name
|
id: branch-name
|
||||||
|
env:
|
||||||
|
RAW_BRANCH: ${{ github.head_ref }}
|
||||||
run: |
|
run: |
|
||||||
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')
|
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||||
|
|
||||||
|
# Additional validation - ensure branch name is not empty after sanitization
|
||||||
|
if [[ -z "$SOURCE_BRANCH" ]]; then
|
||||||
|
echo "❌ Error: Branch name is empty after sanitization"
|
||||||
|
echo "Original branch: $RAW_BRANCH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Safely add to environment variables using GitHub's recommended method
|
# Safely add to environment variables using GitHub's recommended method
|
||||||
# This prevents environment variable injection attacks
|
# This prevents environment variable injection attacks
|
||||||
|
|||||||
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. 😊
|
|
||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -56,21 +56,9 @@ packages/database/migrations
|
|||||||
branch.json
|
branch.json
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# Terraform
|
|
||||||
infra/terraform/.terraform/
|
|
||||||
**/.terraform.lock.hcl
|
|
||||||
**/terraform.tfstate
|
|
||||||
**/terraform.tfstate.*
|
|
||||||
**/crash.log
|
|
||||||
**/override.tf
|
|
||||||
**/override.tf.json
|
|
||||||
**/*.tfvars
|
|
||||||
**/*.tfvars.json
|
|
||||||
**/.terraformrc
|
|
||||||
**/terraform.rc
|
|
||||||
|
|
||||||
# IntelliJ IDEA
|
# IntelliJ IDEA
|
||||||
/.idea/
|
/.idea/
|
||||||
/*.iml
|
/*.iml
|
||||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
.cursorrules
|
.cursorrules
|
||||||
|
i18n.cache
|
||||||
|
|||||||
@@ -31,6 +31,18 @@
|
|||||||
{
|
{
|
||||||
"language": "pt-PT",
|
"language": "pt-PT",
|
||||||
"path": "./apps/web/locales/pt-PT.json"
|
"path": "./apps/web/locales/pt-PT.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "ro-RO",
|
||||||
|
"path": "./apps/web/locales/ro-RO.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "ja-JP",
|
||||||
|
"path": "./apps/web/locales/ja-JP.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "zh-Hans-CN",
|
||||||
|
"path": "./apps/web/locales/zh-Hans-CN.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"forceMode": "OVERRIDE"
|
"forceMode": "OVERRIDE"
|
||||||
|
|||||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||||
|
"eslint.workingDirectories": [{ "mode": "auto" }],
|
||||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||||
"sonarlint.connectedMode.project": {
|
"sonarlint.connectedMode.project": {
|
||||||
"connectionId": "formbricks",
|
"connectionId": "formbricks",
|
||||||
|
|||||||
@@ -14,17 +14,7 @@ Are you brimming with brilliant ideas? For new features that can elevate Formbri
|
|||||||
|
|
||||||
## 🛠 Crafting Pull Requests
|
## 🛠 Crafting Pull Requests
|
||||||
|
|
||||||
Ready to dive into the code and make a real impact? Here's your path:
|
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.
|
||||||
|
|
||||||
1. **Read our Best Practices**: [It takes 5 minutes](https://formbricks.com/docs/developer-docs/contributing/get-started) but will help you save hours 🤓
|
|
||||||
|
|
||||||
1. **Fork the Repository:** Fork our repository or use [Gitpod](https://gitpod.io) or use [Github Codespaces](https://github.com/features/codespaces) to get started instantly.
|
|
||||||
|
|
||||||
1. **Tweak and Transform:** Work your coding magic and apply your changes.
|
|
||||||
|
|
||||||
1. **Pull Request Act:** If you're ready to go, craft a new pull request closely following our PR template 🙏
|
|
||||||
|
|
||||||
Would you prefer a chat before you dive into a lot of work? [Github Discussions](https://github.com/formbricks/formbricks/discussions) is your harbor. Share your thoughts, and we'll meet you there with open arms. We're responsive and friendly, promise!
|
|
||||||
|
|
||||||
## 🚀 Aspiring Features
|
## 🚀 Aspiring Features
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ The Open Source Qualtrics Alternative
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
||||||
|
<a href="https://insights.linuxfoundation.org/project/formbricks"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=formbricks"></a>
|
||||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||||
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||||
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
||||||
@@ -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.
|
- 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
|
## All Thanks To Our Contributors
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* This function is used to resolve the absolute path of a package.
|
||||||
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
* It is needed in projects that use Yarn PnP or are set up within a monorepo.
|
||||||
*/
|
*/
|
||||||
const getAbsolutePath = (value: string) => {
|
function getAbsolutePath(value: string): any {
|
||||||
return dirname(require.resolve(join(value, "package.json")));
|
return dirname(require.resolve(join(value, "package.json")));
|
||||||
};
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
getAbsolutePath("@storybook/addon-onboarding"),
|
getAbsolutePath("@storybook/addon-onboarding"),
|
||||||
getAbsolutePath("@storybook/addon-links"),
|
getAbsolutePath("@storybook/addon-links"),
|
||||||
getAbsolutePath("@storybook/addon-essentials"),
|
|
||||||
getAbsolutePath("@chromatic-com/storybook"),
|
getAbsolutePath("@chromatic-com/storybook"),
|
||||||
getAbsolutePath("@storybook/addon-interactions"),
|
|
||||||
getAbsolutePath("@storybook/addon-a11y"),
|
getAbsolutePath("@storybook/addon-a11y"),
|
||||||
|
getAbsolutePath("@storybook/addon-docs"),
|
||||||
],
|
],
|
||||||
framework: {
|
framework: {
|
||||||
name: getAbsolutePath("@storybook/react-vite"),
|
name: getAbsolutePath("@storybook/react-vite"),
|
||||||
|
|||||||
@@ -1,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 "../../web/modules/ui/globals.css";
|
||||||
|
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||||
|
|
||||||
|
// Create a Storybook-specific Tolgee decorator
|
||||||
|
const withTolgee = (Story: any) => {
|
||||||
|
const tolgee = TolgeeBase().init({
|
||||||
|
tagNewKeys: [], // No branch tagging in Storybook
|
||||||
|
});
|
||||||
|
|
||||||
|
return React.createElement(
|
||||||
|
TolgeeProvider,
|
||||||
|
{
|
||||||
|
tolgee,
|
||||||
|
fallback: "Loading",
|
||||||
|
ssr: {
|
||||||
|
language: "en-US",
|
||||||
|
staticData: {
|
||||||
|
"en-US": enUSTranslations,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
React.createElement(Story)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -10,6 +37,7 @@ const preview: Preview = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
decorators: [withTolgee],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
@@ -14,23 +14,19 @@
|
|||||||
"eslint-plugin-react-refresh": "0.4.20"
|
"eslint-plugin-react-refresh": "0.4.20"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "3.2.6",
|
"@chromatic-com/storybook": "^4.0.1",
|
||||||
"@storybook/addon-a11y": "8.6.12",
|
"@storybook/addon-a11y": "9.0.15",
|
||||||
"@storybook/addon-essentials": "8.6.12",
|
"@storybook/addon-links": "9.0.15",
|
||||||
"@storybook/addon-interactions": "8.6.12",
|
"@storybook/addon-onboarding": "9.0.15",
|
||||||
"@storybook/addon-links": "8.6.12",
|
"@storybook/react-vite": "9.0.15",
|
||||||
"@storybook/addon-onboarding": "8.6.12",
|
|
||||||
"@storybook/blocks": "8.6.12",
|
|
||||||
"@storybook/react": "8.6.12",
|
|
||||||
"@storybook/react-vite": "8.6.12",
|
|
||||||
"@storybook/test": "8.6.12",
|
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||||
"@typescript-eslint/parser": "8.32.0",
|
"@typescript-eslint/parser": "8.32.0",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@vitejs/plugin-react": "4.4.1",
|
||||||
"esbuild": "0.25.4",
|
"esbuild": "0.25.4",
|
||||||
"eslint-plugin-storybook": "0.12.0",
|
"eslint-plugin-storybook": "9.0.15",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "8.6.12",
|
"storybook": "9.0.15",
|
||||||
"vite": "6.3.5"
|
"vite": "6.3.6",
|
||||||
|
"@storybook/addon-docs": "9.0.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Meta } from "@storybook/blocks";
|
import { Meta } from "@storybook/addon-docs/blocks";
|
||||||
|
|
||||||
import Accessibility from "./assets/accessibility.png";
|
import Accessibility from "./assets/accessibility.png";
|
||||||
import AddonLibrary from "./assets/addon-library.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
|
## step 1: Prune monorepo
|
||||||
@@ -30,9 +30,13 @@ COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
|||||||
RUN chmod +x /tmp/read-secrets.sh
|
RUN chmod +x /tmp/read-secrets.sh
|
||||||
|
|
||||||
# Increase Node.js memory limit as a regular build argument
|
# Increase Node.js memory limit as a regular build argument
|
||||||
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
ARG NODE_OPTIONS="--max_old_space_size=8192"
|
||||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||||
|
|
||||||
|
# Target architecture - automatically provided by Docker in multi-platform builds
|
||||||
|
# but needs explicit declaration for some build systems (like Depot)
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -57,6 +61,8 @@ RUN pnpm build --filter=@formbricks/database
|
|||||||
# This mounts the secrets only during this build step without storing them in layers
|
# This mounts the secrets only during this build step without storing them in layers
|
||||||
RUN --mount=type=secret,id=database_url \
|
RUN --mount=type=secret,id=database_url \
|
||||||
--mount=type=secret,id=encryption_key \
|
--mount=type=secret,id=encryption_key \
|
||||||
|
--mount=type=secret,id=redis_url \
|
||||||
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
# Extract Prisma version
|
# Extract Prisma version
|
||||||
@@ -109,9 +115,6 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
|||||||
COPY --from=installer /prisma_version.txt .
|
COPY --from=installer /prisma_version.txt .
|
||||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||||
|
|
||||||
COPY /docker/cronjobs /app/docker/cronjobs
|
|
||||||
RUN chmod -R 755 /app/docker/cronjobs
|
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
const webAppUrl = "http://app";
|
const webAppUrl = "http://app";
|
||||||
const channel = {} as any;
|
const channel = {} as any;
|
||||||
|
|
||||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
test("renders waiting state when appSetupCompleted is false", () => {
|
||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
publicDomain={webAppUrl}
|
||||||
widgetSetupCompleted={false}
|
appSetupCompleted={false}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -36,12 +36,12 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders success state when widgetSetupCompleted is true", () => {
|
test("renders success state when appSetupCompleted is true", () => {
|
||||||
render(
|
render(
|
||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
publicDomain={webAppUrl}
|
||||||
widgetSetupCompleted={true}
|
appSetupCompleted={true}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -54,7 +54,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
publicDomain={webAppUrl}
|
||||||
widgetSetupCompleted={true}
|
appSetupCompleted={true}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -68,7 +68,7 @@ describe("ConnectWithFormbricks", () => {
|
|||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={webAppUrl}
|
publicDomain={webAppUrl}
|
||||||
widgetSetupCompleted={false}
|
appSetupCompleted={false}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
|||||||
interface ConnectWithFormbricksProps {
|
interface ConnectWithFormbricksProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
widgetSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
channel: TProjectConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ConnectWithFormbricks = ({
|
export const ConnectWithFormbricks = ({
|
||||||
environment,
|
environment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
widgetSetupCompleted,
|
appSetupCompleted,
|
||||||
channel,
|
channel,
|
||||||
}: ConnectWithFormbricksProps) => {
|
}: ConnectWithFormbricksProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
@@ -51,15 +51,15 @@ export const ConnectWithFormbricks = ({
|
|||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
widgetSetupCompleted={widgetSetupCompleted}
|
appSetupCompleted={appSetupCompleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
||||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
appSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||||
)}>
|
)}>
|
||||||
{widgetSetupCompleted ? (
|
{appSetupCompleted ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
@@ -81,9 +81,9 @@ export const ConnectWithFormbricks = ({
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
id="finishOnboarding"
|
id="finishOnboarding"
|
||||||
variant={widgetSetupCompleted ? "default" : "ghost"}
|
variant={appSetupCompleted ? "default" : "ghost"}
|
||||||
onClick={handleFinishOnboarding}>
|
onClick={handleFinishOnboarding}>
|
||||||
{widgetSetupCompleted
|
{appSetupCompleted
|
||||||
? t("environments.connect.finish_onboarding")
|
? t("environments.connect.finish_onboarding")
|
||||||
: t("environments.connect.do_it_later")}
|
: t("environments.connect.do_it_later")}
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ describe("OnboardingSetupInstructions", () => {
|
|||||||
environmentId: "env-123",
|
environmentId: "env-123",
|
||||||
publicDomain: "https://example.com",
|
publicDomain: "https://example.com",
|
||||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||||
widgetSetupCompleted: false,
|
appSetupCompleted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
test("renders HTML tab content by default", () => {
|
test("renders HTML tab content by default", () => {
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ interface OnboardingSetupInstructionsProps {
|
|||||||
environmentId: string;
|
environmentId: string;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
channel: TProjectConfigChannel;
|
channel: TProjectConfigChannel;
|
||||||
widgetSetupCompleted: boolean;
|
appSetupCompleted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingSetupInstructions = ({
|
export const OnboardingSetupInstructions = ({
|
||||||
environmentId,
|
environmentId,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
channel,
|
channel,
|
||||||
widgetSetupCompleted,
|
appSetupCompleted,
|
||||||
}: OnboardingSetupInstructionsProps) => {
|
}: OnboardingSetupInstructionsProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||||
@@ -137,7 +137,7 @@ export const OnboardingSetupInstructions = ({
|
|||||||
<div className="mt-4 flex justify-between space-x-2">
|
<div className="mt-4 flex justify-between space-x-2">
|
||||||
<Button
|
<Button
|
||||||
id="onboarding-inapp-connect-copy-code"
|
id="onboarding-inapp-connect-copy-code"
|
||||||
variant={widgetSetupCompleted ? "secondary" : "default"}
|
variant={appSetupCompleted ? "secondary" : "default"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
navigator.clipboard.writeText(
|
navigator.clipboard.writeText(
|
||||||
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
|
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
<ConnectWithFormbricks
|
<ConnectWithFormbricks
|
||||||
environment={environment}
|
environment={environment}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
widgetSetupCompleted={environment.appSetupCompleted}
|
appSetupCompleted={environment.appSetupCompleted}
|
||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { TFnType } from "@tolgee/react";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import {
|
import {
|
||||||
buildCTAQuestion,
|
buildCTAQuestion,
|
||||||
buildNPSQuestion,
|
buildNPSQuestion,
|
||||||
@@ -5,10 +9,6 @@ import {
|
|||||||
buildRatingQuestion,
|
buildRatingQuestion,
|
||||||
getDefaultEndingCard,
|
getDefaultEndingCard,
|
||||||
} from "@/app/lib/survey-builder";
|
} from "@/app/lib/survey-builder";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
|
||||||
import { TFnType } from "@tolgee/react";
|
|
||||||
import { logger } from "@formbricks/logger";
|
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
|
||||||
|
|
||||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
||||||
try {
|
try {
|
||||||
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
html: t("templates.star_rating_survey_question_2_html"),
|
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
}),
|
}),
|
||||||
buildCTAQuestion({
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
html: t("templates.smileys_survey_question_2_html"),
|
subheader: t("templates.smileys_survey_question_2_html"),
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
|
|||||||
@@ -45,22 +45,11 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("LandingSidebar component", () => {
|
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 organization = { id: "o1", name: "orgOne" } as any;
|
||||||
const organizations = [
|
|
||||||
{ id: "o2", name: "betaOrg" },
|
|
||||||
{ id: "o1", name: "alphaOrg" },
|
|
||||||
] as any;
|
|
||||||
|
|
||||||
test("renders logo, avatar, and initial modal closed", () => {
|
test("renders logo, avatar, and initial modal closed", () => {
|
||||||
render(
|
render(<LandingSidebar user={user} organization={organization} />);
|
||||||
<LandingSidebar
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
user={user}
|
|
||||||
organization={organization}
|
|
||||||
organizations={organizations}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Formbricks logo
|
// Formbricks logo
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||||
@@ -71,14 +60,7 @@ describe("LandingSidebar component", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking logout triggers signOut", async () => {
|
test("clicking logout triggers signOut", async () => {
|
||||||
render(
|
render(<LandingSidebar user={user} organization={organization} />);
|
||||||
<LandingSidebar
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
user={user}
|
|
||||||
organization={organization}
|
|
||||||
organizations={organizations}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Open user dropdown by clicking on avatar trigger
|
// Open user dropdown by clicking on avatar trigger
|
||||||
const trigger = screen.getByTestId("avatar").parentElement;
|
const trigger = screen.getByTestId("avatar").parentElement;
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
@@ -10,48 +16,20 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
|
||||||
|
|
||||||
interface LandingSidebarProps {
|
interface LandingSidebarProps {
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
organizations: TOrganization[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LandingSidebar = ({
|
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||||
isMultiOrgEnabled,
|
|
||||||
user,
|
|
||||||
organization,
|
|
||||||
organizations,
|
|
||||||
}: LandingSidebarProps) => {
|
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||||
|
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
{
|
{
|
||||||
label: t("common.documentation"),
|
label: t("common.documentation"),
|
||||||
@@ -61,13 +39,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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -80,27 +51,26 @@ export const LandingSidebar = ({
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
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">
|
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
<button
|
||||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
type="button"
|
||||||
<>
|
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
||||||
<div>
|
aria-haspopup="menu">
|
||||||
<p
|
<ProfileAvatar userId={user.id} />
|
||||||
title={user?.email}
|
<div className="grow overflow-hidden">
|
||||||
className={cn(
|
<p
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
title={user?.email}
|
||||||
)}>
|
className={cn(
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
||||||
</p>
|
)}>
|
||||||
<p
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
title={capitalizeFirstLetter(organization?.name)}
|
</p>
|
||||||
className="max-w-28 truncate text-sm text-slate-500">
|
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
||||||
{capitalizeFirstLetter(organization?.name)}
|
{organization?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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")} />
|
||||||
</>
|
</button>
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -112,7 +82,13 @@ export const LandingSidebar = ({
|
|||||||
{/* Dropdown Items */}
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{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>
|
<DropdownMenuItem>
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
{link.label}
|
{link.label}
|
||||||
@@ -121,7 +97,6 @@ export const LandingSidebar = ({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOutWithAudit({
|
await signOutWithAudit({
|
||||||
@@ -136,45 +111,6 @@ export const LandingSidebar = ({
|
|||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* Organization Switch */}
|
|
||||||
|
|
||||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p>{currentOrganizationName}</p>
|
|
||||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={currentOrganizationId}
|
|
||||||
onValueChange={(organizationId) =>
|
|
||||||
handleEnvironmentChangeByOrganization(organizationId)
|
|
||||||
}>
|
|
||||||
{sortedOrganizations.map((organization) => (
|
|
||||||
<DropdownMenuRadioItem
|
|
||||||
value={organization.id}
|
|
||||||
className="cursor-pointer rounded-lg"
|
|
||||||
key={organization.id}>
|
|
||||||
{organization.name}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
|
||||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
|
||||||
<span>{t("common.create_new_organization")}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
@@ -15,6 +16,7 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
|
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
@@ -97,20 +99,36 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/getPublicUrl", () => ({
|
||||||
|
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
||||||
|
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
||||||
|
}));
|
||||||
vi.mock("@/modules/organization/lib/utils");
|
vi.mock("@/modules/organization/lib/utils");
|
||||||
vi.mock("@/lib/user/service");
|
vi.mock("@/lib/user/service");
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
|
vi.mock("@/lib/membership/service");
|
||||||
vi.mock("@/tolgee/server");
|
vi.mock("@/tolgee/server");
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||||
|
usePathname: vi.fn(() => "/organizations/org1"),
|
||||||
|
useRouter: vi.fn(() => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
forward: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the React cache function
|
// Mock the React cache function
|
||||||
@@ -142,6 +160,7 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
|
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
const result = await Page({ params: { organizationId: "org1" } });
|
||||||
@@ -163,6 +182,7 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
|
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
const result = await Page({ params: { organizationId: "org1" } });
|
||||||
@@ -173,10 +193,16 @@ describe("Page component", () => {
|
|||||||
test("renders header and sidebar for authenticated user", async () => {
|
test("renders header and sidebar for authenticated user", async () => {
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||||
session: { user: { id: "user1" } },
|
session: { user: { id: "user1" } },
|
||||||
organization: { id: "org1" },
|
organization: { id: "org1", billing: { plan: "free" } },
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||||
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
||||||
|
organizationId: "org1",
|
||||||
|
userId: "user1",
|
||||||
|
accepted: true,
|
||||||
|
role: "member",
|
||||||
|
} as any);
|
||||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||||
typeof props === "string" ? props : props.key || ""
|
typeof props === "string" ? props : props.key || ""
|
||||||
);
|
);
|
||||||
@@ -188,11 +214,13 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
|
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const element = await Page({ params: { organizationId: "org1" } });
|
const element = await Page({ params: { organizationId: "org1" } });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
@@ -22,24 +26,38 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
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 (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
<LandingSidebar
|
<LandingSidebar user={user} organization={organization} />
|
||||||
user={user}
|
|
||||||
organization={organization}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizations={organizations}
|
|
||||||
/>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col">
|
||||||
<Header
|
<div className="p-6">
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
WEBAPP_URL: "test-webapp-url",
|
WEBAPP_URL: "test-webapp-url",
|
||||||
IS_PRODUCTION: false,
|
IS_PRODUCTION: false,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
REDIS_URL: "test-redis-url",
|
REDIS_URL: undefined,
|
||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
|
|||||||
industry: "ind",
|
industry: "ind",
|
||||||
defaultBrandColor: "#fff",
|
defaultBrandColor: "#fff",
|
||||||
organizationTeams: [],
|
organizationTeams: [],
|
||||||
canDoRoleManagement: false,
|
isAccessControlAllowed: false,
|
||||||
userProjectsCount: 0,
|
userProjectsCount: 0,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
|
|||||||
industry: TProjectConfigIndustry;
|
industry: TProjectConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
canDoRoleManagement: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
|
|||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
canDoRoleManagement = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{canDoRoleManagement && userProjectsCount > 0 && (
|
{isAccessControlAllowed && userProjectsCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
|||||||
// Mocks before component import
|
// Mocks before component import
|
||||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ 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("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
|
||||||
|
|
||||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
|||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { 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 { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
canDoRoleManagement={canDoRoleManagement}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
|
|||||||
@@ -18,11 +18,6 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
|
||||||
DevEnvironmentBanner: ({ environment }: any) => (
|
|
||||||
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mocks for dependencies
|
// Mocks for dependencies
|
||||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||||
@@ -58,7 +53,6 @@ describe("SurveyEditorEnvironmentLayout", () => {
|
|||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||||
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
|
||||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -32,7 +31,6 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
user={user}
|
user={user}
|
||||||
organization={organization}>
|
organization={organization}>
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
<DevEnvironmentBanner environment={environment} />
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</EnvironmentIdBaseLayout>
|
</EnvironmentIdBaseLayout>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
IS_POSTHOG_CONFIGURED: true,
|
IS_POSTHOG_CONFIGURED: true,
|
||||||
SESSION_MAX_AGE: 1000,
|
SESSION_MAX_AGE: 1000,
|
||||||
AUDIT_LOG_ENABLED: 1,
|
AUDIT_LOG_ENABLED: 1,
|
||||||
REDIS_URL: "redis://localhost:6379",
|
REDIS_URL: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/env", () => ({
|
vi.mock("@/lib/env", () => ({
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
|||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import {
|
import {
|
||||||
|
getAccessControlPermission,
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
getRoleManagementPermission,
|
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
const 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");
|
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,10 +71,6 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
alert: {
|
alert: {
|
||||||
...user.notificationSettings?.alert,
|
...user.notificationSettings?.alert,
|
||||||
},
|
},
|
||||||
weeklySummary: {
|
|
||||||
...user.notificationSettings?.weeklySummary,
|
|
||||||
[project.id]: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateUser(user.id, {
|
await updateUser(user.id, {
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
import { ModalWithTabs } from "@/modules/ui/components/modal-with-tabs";
|
|
||||||
import { cleanup, render } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TActionClass } from "@formbricks/types/action-classes";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { ActionActivityTab } from "./ActionActivityTab";
|
|
||||||
import { ActionDetailModal } from "./ActionDetailModal";
|
|
||||||
// Import mocked components
|
|
||||||
import { ActionSettingsTab } from "./ActionSettingsTab";
|
|
||||||
|
|
||||||
// Mock child components
|
|
||||||
vi.mock("@/modules/ui/components/modal-with-tabs", () => ({
|
|
||||||
ModalWithTabs: vi.fn(({ tabs, icon, label, description, open, setOpen }) => (
|
|
||||||
<div data-testid="modal-with-tabs">
|
|
||||||
<span data-testid="modal-label">{label}</span>
|
|
||||||
<span data-testid="modal-description">{description}</span>
|
|
||||||
<span data-testid="modal-open">{open.toString()}</span>
|
|
||||||
<button onClick={() => setOpen(false)}>Close</button>
|
|
||||||
{icon}
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<div key={tab.title}>
|
|
||||||
<h2>{tab.title}</h2>
|
|
||||||
{tab.children}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./ActionActivityTab", () => ({
|
|
||||||
ActionActivityTab: vi.fn(() => <div data-testid="action-activity-tab">ActionActivityTab</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("./ActionSettingsTab", () => ({
|
|
||||||
ActionSettingsTab: vi.fn(() => <div data-testid="action-settings-tab">ActionSettingsTab</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the utils file to control ACTION_TYPE_ICON_LOOKUP
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/utils", () => ({
|
|
||||||
ACTION_TYPE_ICON_LOOKUP: {
|
|
||||||
code: <div data-testid="code-icon">Code Icon Mock</div>,
|
|
||||||
noCode: <div data-testid="nocode-icon">No Code Icon Mock</div>,
|
|
||||||
// Add other types if needed by other tests or default props
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEnvironmentId = "test-env-id";
|
|
||||||
const mockSetOpen = vi.fn();
|
|
||||||
|
|
||||||
const mockEnvironment = {
|
|
||||||
id: mockEnvironmentId,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production", // Use string literal as TEnvironmentType is not exported
|
|
||||||
appSetupCompleted: false,
|
|
||||||
} as unknown as TEnvironment;
|
|
||||||
|
|
||||||
const mockActionClass: TActionClass = {
|
|
||||||
id: "action-class-1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
name: "Test Action",
|
|
||||||
description: "This is a test action",
|
|
||||||
type: "code", // Ensure this matches a key in the mocked ACTION_TYPE_ICON_LOOKUP
|
|
||||||
environmentId: mockEnvironmentId,
|
|
||||||
noCodeConfig: null,
|
|
||||||
key: "test-action-key",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockActionClasses: TActionClass[] = [mockActionClass];
|
|
||||||
const mockOtherEnvActionClasses: TActionClass[] = [];
|
|
||||||
const mockOtherEnvironment = { ...mockEnvironment, id: "other-env-id", name: "Other Environment" };
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
environmentId: mockEnvironmentId,
|
|
||||||
environment: mockEnvironment,
|
|
||||||
open: true,
|
|
||||||
setOpen: mockSetOpen,
|
|
||||||
actionClass: mockActionClass,
|
|
||||||
actionClasses: mockActionClasses,
|
|
||||||
isReadOnly: false,
|
|
||||||
otherEnvironment: mockOtherEnvironment,
|
|
||||||
otherEnvActionClasses: mockOtherEnvActionClasses,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("ActionDetailModal", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks(); // Clear mocks after each test
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders ModalWithTabs with correct props", () => {
|
|
||||||
render(<ActionDetailModal {...defaultProps} />);
|
|
||||||
|
|
||||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
|
||||||
|
|
||||||
expect(mockedModalWithTabs).toHaveBeenCalled();
|
|
||||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
|
||||||
|
|
||||||
// Check basic props
|
|
||||||
expect(props.open).toBe(true);
|
|
||||||
expect(props.setOpen).toBe(mockSetOpen);
|
|
||||||
expect(props.label).toBe(mockActionClass.name);
|
|
||||||
expect(props.description).toBe(mockActionClass.description);
|
|
||||||
|
|
||||||
// Check icon data-testid based on the mock for the default 'code' type
|
|
||||||
expect(props.icon).toBeDefined();
|
|
||||||
if (!props.icon) {
|
|
||||||
throw new Error("Icon prop is not defined");
|
|
||||||
}
|
|
||||||
expect((props.icon as any).props["data-testid"]).toBe("code-icon");
|
|
||||||
|
|
||||||
// Check tabs structure
|
|
||||||
expect(props.tabs).toHaveLength(2);
|
|
||||||
expect(props.tabs[0].title).toBe("common.activity");
|
|
||||||
expect(props.tabs[1].title).toBe("common.settings");
|
|
||||||
|
|
||||||
// Check if the correct mocked components are used as children
|
|
||||||
// Access the mocked functions directly
|
|
||||||
const mockedActionActivityTab = vi.mocked(ActionActivityTab);
|
|
||||||
const mockedActionSettingsTab = vi.mocked(ActionSettingsTab);
|
|
||||||
|
|
||||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
|
||||||
throw new Error("Tabs children are not defined");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((props.tabs[0].children as any).type).toBe(mockedActionActivityTab);
|
|
||||||
expect((props.tabs[1].children as any).type).toBe(mockedActionSettingsTab);
|
|
||||||
|
|
||||||
// Check props passed to child components
|
|
||||||
const activityTabProps = (props.tabs[0].children as any).props;
|
|
||||||
expect(activityTabProps.otherEnvActionClasses).toBe(mockOtherEnvActionClasses);
|
|
||||||
expect(activityTabProps.otherEnvironment).toBe(mockOtherEnvironment);
|
|
||||||
expect(activityTabProps.isReadOnly).toBe(false);
|
|
||||||
expect(activityTabProps.environment).toBe(mockEnvironment);
|
|
||||||
expect(activityTabProps.actionClass).toBe(mockActionClass);
|
|
||||||
expect(activityTabProps.environmentId).toBe(mockEnvironmentId);
|
|
||||||
|
|
||||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
|
||||||
expect(settingsTabProps.actionClass).toBe(mockActionClass);
|
|
||||||
expect(settingsTabProps.actionClasses).toBe(mockActionClasses);
|
|
||||||
expect(settingsTabProps.setOpen).toBe(mockSetOpen);
|
|
||||||
expect(settingsTabProps.isReadOnly).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correct icon based on action type", () => {
|
|
||||||
// Test with 'noCode' type
|
|
||||||
const noCodeAction: TActionClass = { ...mockActionClass, type: "noCode" } as TActionClass;
|
|
||||||
render(<ActionDetailModal {...defaultProps} actionClass={noCodeAction} />);
|
|
||||||
|
|
||||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
|
||||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
|
||||||
|
|
||||||
// Expect the 'nocode-icon' based on the updated mock and action type
|
|
||||||
expect(props.icon).toBeDefined();
|
|
||||||
|
|
||||||
if (!props.icon) {
|
|
||||||
throw new Error("Icon prop is not defined");
|
|
||||||
}
|
|
||||||
|
|
||||||
expect((props.icon as any).props["data-testid"]).toBe("nocode-icon");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes isReadOnly prop correctly", () => {
|
|
||||||
render(<ActionDetailModal {...defaultProps} isReadOnly={true} />);
|
|
||||||
// Access the mocked component directly
|
|
||||||
const mockedModalWithTabs = vi.mocked(ModalWithTabs);
|
|
||||||
const props = mockedModalWithTabs.mock.calls[0][0];
|
|
||||||
|
|
||||||
if (!props.tabs[0].children || !props.tabs[1].children) {
|
|
||||||
throw new Error("Tabs children are not defined");
|
|
||||||
}
|
|
||||||
|
|
||||||
const activityTabProps = (props.tabs[0].children as any).props;
|
|
||||||
expect(activityTabProps.isReadOnly).toBe(true);
|
|
||||||
|
|
||||||
const settingsTabProps = (props.tabs[1].children as any).props;
|
|
||||||
expect(settingsTabProps.isReadOnly).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
deleteActionClassAction,
|
|
||||||
updateActionClassAction,
|
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
|
||||||
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
|
||||||
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
|
||||||
import { Input } from "@/modules/ui/components/input";
|
|
||||||
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { TrashIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
|
||||||
import { toast } from "react-hot-toast";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
|
||||||
|
|
||||||
interface ActionSettingsTabProps {
|
|
||||||
actionClass: TActionClass;
|
|
||||||
actionClasses: TActionClass[];
|
|
||||||
setOpen: (v: boolean) => void;
|
|
||||||
isReadOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ActionSettingsTab = ({
|
|
||||||
actionClass,
|
|
||||||
actionClasses,
|
|
||||||
setOpen,
|
|
||||||
isReadOnly,
|
|
||||||
}: ActionSettingsTabProps) => {
|
|
||||||
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
|
|
||||||
const router = useRouter();
|
|
||||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [isUpdatingAction, setIsUpdatingAction] = useState(false);
|
|
||||||
const [isDeletingAction, setIsDeletingAction] = useState(false);
|
|
||||||
|
|
||||||
const actionClassNames = useMemo(
|
|
||||||
() =>
|
|
||||||
actionClasses.filter((action) => action.id !== actionClass.id).map((actionClass) => actionClass.name),
|
|
||||||
[actionClass.id, actionClasses]
|
|
||||||
);
|
|
||||||
|
|
||||||
const form = useForm<TActionClassInput>({
|
|
||||||
defaultValues: {
|
|
||||||
...restActionClass,
|
|
||||||
},
|
|
||||||
resolver: zodResolver(
|
|
||||||
ZActionClassInput.superRefine((data, ctx) => {
|
|
||||||
if (data.name && actionClassNames.includes(data.name)) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["name"],
|
|
||||||
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
),
|
|
||||||
|
|
||||||
mode: "onChange",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { handleSubmit, control } = form;
|
|
||||||
|
|
||||||
const onSubmit = async (data: TActionClassInput) => {
|
|
||||||
try {
|
|
||||||
if (isReadOnly) {
|
|
||||||
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
|
||||||
}
|
|
||||||
setIsUpdatingAction(true);
|
|
||||||
|
|
||||||
if (data.name && actionClassNames.includes(data.name)) {
|
|
||||||
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
data.type === "noCode" &&
|
|
||||||
data.noCodeConfig?.type === "click" &&
|
|
||||||
data.noCodeConfig.elementSelector.cssSelector &&
|
|
||||||
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
|
||||||
) {
|
|
||||||
throw new Error(t("environments.actions.invalid_css_selector"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedData: TActionClassInput = {
|
|
||||||
...data,
|
|
||||||
...(data.type === "noCode" &&
|
|
||||||
data.noCodeConfig?.type === "click" && {
|
|
||||||
noCodeConfig: {
|
|
||||||
...data.noCodeConfig,
|
|
||||||
elementSelector: {
|
|
||||||
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
|
||||||
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
await updateActionClassAction({
|
|
||||||
actionClassId: actionClass.id,
|
|
||||||
updatedAction: updatedData,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
router.refresh();
|
|
||||||
toast.success(t("environments.actions.action_updated_successfully"));
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(error.message);
|
|
||||||
} finally {
|
|
||||||
setIsUpdatingAction(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteAction = async () => {
|
|
||||||
try {
|
|
||||||
setIsDeletingAction(true);
|
|
||||||
await deleteActionClassAction({ actionClassId: actionClass.id });
|
|
||||||
router.refresh();
|
|
||||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
|
||||||
setOpen(false);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
|
||||||
} finally {
|
|
||||||
setIsDeletingAction(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<FormProvider {...form}>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
|
||||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
|
||||||
<div className="grid w-full grid-cols-2 gap-x-4">
|
|
||||||
<div className="col-span-1">
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="name"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
render={({ field, fieldState: { error } }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="actionNameSettingsInput">
|
|
||||||
{actionClass.type === "noCode"
|
|
||||||
? t("environments.actions.what_did_your_user_do")
|
|
||||||
: t("environments.actions.display_name")}
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="actionNameSettingsInput"
|
|
||||||
{...field}
|
|
||||||
placeholder={t("environments.actions.eg_clicked_download")}
|
|
||||||
isInvalid={!!error?.message}
|
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
|
|
||||||
<FormError />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-1">
|
|
||||||
<FormField
|
|
||||||
control={control}
|
|
||||||
name="description"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
|
||||||
{t("common.description")}
|
|
||||||
</FormLabel>
|
|
||||||
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
id="actionDescriptionSettingsInput"
|
|
||||||
{...field}
|
|
||||||
placeholder={t("environments.actions.user_clicked_download_button")}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
disabled={isReadOnly}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{actionClass.type === "code" ? (
|
|
||||||
<>
|
|
||||||
<CodeActionForm form={form} isReadOnly={true} />
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : actionClass.type === "noCode" ? (
|
|
||||||
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t(
|
|
||||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
|
||||||
<div>
|
|
||||||
{!isReadOnly ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() => setOpenDeleteDialog(true)}
|
|
||||||
className="mr-3"
|
|
||||||
id="deleteActionModalTrigger">
|
|
||||||
<TrashIcon />
|
|
||||||
{t("common.delete")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button variant="secondary" asChild>
|
|
||||||
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
|
|
||||||
{t("common.read_docs")}
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isReadOnly ? (
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button type="submit" loading={isUpdatingAction}>
|
|
||||||
{t("common.save_changes")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</FormProvider>
|
|
||||||
|
|
||||||
<DeleteDialog
|
|
||||||
open={openDeleteDialog}
|
|
||||||
setOpen={setOpenDeleteDialog}
|
|
||||||
isDeleting={isDeletingAction}
|
|
||||||
deleteWhat={t("common.action")}
|
|
||||||
text={t("environments.actions.delete_action_text")}
|
|
||||||
onDelete={handleDeleteAction}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
|
|
||||||
export const ActionTableHeading = async () => {
|
|
||||||
const t = await getTranslate();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
|
||||||
<span className="sr-only">{t("common.edit")}</span>
|
|
||||||
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
|
|
||||||
<div className="col-span-2 text-center">{t("common.created")}</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TActionClass, TActionClassNoCodeConfig } from "@formbricks/types/action-classes";
|
|
||||||
import { AddActionModal } from "./AddActionModal";
|
|
||||||
|
|
||||||
// Mock child components and hooks
|
|
||||||
vi.mock("@/modules/survey/editor/components/create-new-action-tab", () => ({
|
|
||||||
CreateNewActionTab: vi.fn(({ setOpen }) => (
|
|
||||||
<div data-testid="create-new-action-tab">
|
|
||||||
<span>CreateNewActionTab Content</span>
|
|
||||||
<button onClick={() => setOpen(false)}>Close from Tab</button>
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/button", () => ({
|
|
||||||
Button: ({ children, onClick, ...props }: any) => (
|
|
||||||
<button onClick={onClick} {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/modal", () => ({
|
|
||||||
Modal: ({ children, open, setOpen, ...props }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="modal" {...props}>
|
|
||||||
{children}
|
|
||||||
<button onClick={() => setOpen(false)}>Close Modal</button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
MousePointerClickIcon: () => <div data-testid="mouse-pointer-icon" />,
|
|
||||||
PlusIcon: () => <div data-testid="plus-icon" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockActionClasses: TActionClass[] = [
|
|
||||||
{
|
|
||||||
id: "action1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
name: "Action 1",
|
|
||||||
description: "Description 1",
|
|
||||||
type: "noCode",
|
|
||||||
environmentId: "env1",
|
|
||||||
noCodeConfig: { type: "click" } as unknown as TActionClassNoCodeConfig,
|
|
||||||
} as unknown as TActionClass,
|
|
||||||
];
|
|
||||||
|
|
||||||
const environmentId = "env1";
|
|
||||||
|
|
||||||
describe("AddActionModal", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders the 'Add Action' button initially", () => {
|
|
||||||
render(
|
|
||||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("button", { name: "common.add_action" })).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens the modal when the 'Add Action' button is clicked", async () => {
|
|
||||||
render(
|
|
||||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
|
||||||
);
|
|
||||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
|
||||||
await userEvent.click(addButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("mouse-pointer-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("environments.actions.track_new_user_action")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("create-new-action-tab")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes correct props to CreateNewActionTab", async () => {
|
|
||||||
const { CreateNewActionTab } = await import("@/modules/survey/editor/components/create-new-action-tab");
|
|
||||||
const mockedCreateNewActionTab = vi.mocked(CreateNewActionTab);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
|
||||||
);
|
|
||||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
|
||||||
await userEvent.click(addButton);
|
|
||||||
|
|
||||||
expect(mockedCreateNewActionTab).toHaveBeenCalled();
|
|
||||||
const props = mockedCreateNewActionTab.mock.calls[0][0];
|
|
||||||
expect(props.environmentId).toBe(environmentId);
|
|
||||||
expect(props.actionClasses).toEqual(mockActionClasses); // Initial state check
|
|
||||||
expect(props.isReadOnly).toBe(false);
|
|
||||||
expect(props.setOpen).toBeInstanceOf(Function);
|
|
||||||
expect(props.setActionClasses).toBeInstanceOf(Function);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes the modal when the close button (simulated) is clicked", async () => {
|
|
||||||
render(
|
|
||||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
|
||||||
);
|
|
||||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
|
||||||
await userEvent.click(addButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Simulate closing via the mocked Modal's close button
|
|
||||||
const closeModalButton = screen.getByText("Close Modal");
|
|
||||||
await userEvent.click(closeModalButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes the modal when setOpen is called from CreateNewActionTab", async () => {
|
|
||||||
render(
|
|
||||||
<AddActionModal environmentId={environmentId} actionClasses={mockActionClasses} isReadOnly={false} />
|
|
||||||
);
|
|
||||||
const addButton = screen.getByRole("button", { name: "common.add_action" });
|
|
||||||
await userEvent.click(addButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Simulate closing via the mocked CreateNewActionTab's button
|
|
||||||
const closeFromTabButton = screen.getByText("Close from Tab");
|
|
||||||
await userEvent.click(closeFromTabButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { Modal } from "@/modules/ui/components/modal";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { TActionClass } from "@formbricks/types/action-classes";
|
|
||||||
|
|
||||||
interface AddActionModalProps {
|
|
||||||
environmentId: string;
|
|
||||||
actionClasses: TActionClass[];
|
|
||||||
isReadOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddActionModal = ({ environmentId, actionClasses, isReadOnly }: AddActionModalProps) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const [newActionClasses, setNewActionClasses] = useState<TActionClass[]>(actionClasses);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button size="sm" onClick={() => setOpen(true)}>
|
|
||||||
{t("common.add_action")}
|
|
||||||
<PlusIcon />
|
|
||||||
</Button>
|
|
||||||
<Modal open={open} setOpen={setOpen} noPadding closeOnOutsideClick={false} restrictOverflow>
|
|
||||||
<div className="flex h-full flex-col rounded-lg">
|
|
||||||
<div className="rounded-t-lg bg-slate-100">
|
|
||||||
<div className="flex w-full items-center justify-between p-6">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
|
||||||
<MousePointerClickIcon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xl font-medium text-slate-700">
|
|
||||||
{t("environments.actions.track_new_user_action")}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-slate-500">
|
|
||||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-4">
|
|
||||||
<CreateNewActionTab
|
|
||||||
actionClasses={newActionClasses}
|
|
||||||
environmentId={environmentId}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
setActionClasses={setNewActionClasses}
|
|
||||||
setOpen={setOpen}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import Loading from "./loading";
|
|
||||||
|
|
||||||
// Mock child components
|
|
||||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
|
||||||
PageContentWrapper: ({ children }: { children: React.ReactNode }) => (
|
|
||||||
<div data-testid="page-content-wrapper">{children}</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
|
||||||
PageHeader: ({ pageTitle }: { pageTitle: string }) => <div data-testid="page-header">{pageTitle}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Loading", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders loading state correctly", () => {
|
|
||||||
render(<Loading />);
|
|
||||||
|
|
||||||
// Check if mocked components are rendered
|
|
||||||
expect(screen.getByTestId("page-content-wrapper")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("page-header")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("page-header")).toHaveTextContent("common.actions");
|
|
||||||
|
|
||||||
// Check for translated table headers
|
|
||||||
expect(screen.getByText("environments.actions.user_actions")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.created")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.edit")).toBeInTheDocument(); // Screen reader text
|
|
||||||
|
|
||||||
// Check for skeleton elements (presence of animate-pulse class)
|
|
||||||
const skeletonElements = document.querySelectorAll(".animate-pulse");
|
|
||||||
expect(skeletonElements.length).toBeGreaterThan(0); // Ensure some skeleton elements are rendered
|
|
||||||
|
|
||||||
// Check for the presence of multiple skeleton rows (3 rows * 4 pulse elements per row = 12)
|
|
||||||
const pulseDivs = screen.getAllByText((_, element) => {
|
|
||||||
return element?.tagName.toLowerCase() === "div" && element.classList.contains("animate-pulse");
|
|
||||||
});
|
|
||||||
expect(pulseDivs.length).toBe(3 * 4); // 3 rows, 4 pulsing divs per row (icon, name, desc, created)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
|
|
||||||
const Loading = () => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.actions")} />
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
||||||
<div className="grid h-12 grid-cols-6 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
|
||||||
<span className="sr-only">{t("common.edit")}</span>
|
|
||||||
<div className="col-span-4 pl-6">{t("environments.actions.user_actions")}</div>
|
|
||||||
<div className="col-span-2 text-center">{t("common.created")}</div>
|
|
||||||
</div>
|
|
||||||
{[...Array(3)].map((_, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="m-2 grid h-16 grid-cols-6 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100">
|
|
||||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="h-6 w-6 flex-shrink-0 animate-pulse rounded-full bg-slate-200 text-slate-500" />
|
|
||||||
<div className="ml-4 text-left">
|
|
||||||
<div className="font-medium text-slate-900">
|
|
||||||
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-slate-200"></div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 text-xs text-slate-400">
|
|
||||||
<div className="h-2 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 my-auto flex justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
|
||||||
<div className="h-4 w-28 animate-pulse rounded-full bg-slate-200"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageContentWrapper>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default Loading;
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
import { getActionClasses } from "@/lib/actionClass/service";
|
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TActionClass } from "@formbricks/types/action-classes";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
// Import the component after mocks
|
|
||||||
import Page from "./page";
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
vi.mock("@/lib/actionClass/service", () => ({
|
|
||||||
getActionClasses: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/environment/service", () => ({
|
|
||||||
getEnvironments: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/utils/locale", () => ({
|
|
||||||
findMatchingLocale: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
|
||||||
getEnvironmentAuth: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/tolgee/server", () => ({
|
|
||||||
getTranslate: async () => (key: string) => key,
|
|
||||||
}));
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
redirect: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable", () => ({
|
|
||||||
ActionClassesTable: ({ children }) => <div>ActionClassesTable Mock{children}</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionRowData", () => ({
|
|
||||||
ActionClassDataRow: ({ actionClass }) => <div>ActionClassDataRow Mock: {actionClass.name}</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading", () => ({
|
|
||||||
ActionTableHeading: () => <div>ActionTableHeading Mock</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/components/AddActionModal", () => ({
|
|
||||||
AddActionModal: () => <div>AddActionModal Mock</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
|
||||||
PageContentWrapper: ({ children }) => <div>PageContentWrapper Mock{children}</div>,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
|
||||||
PageHeader: ({ pageTitle, cta }) => (
|
|
||||||
<div>
|
|
||||||
PageHeader Mock: {pageTitle} {cta && <div>CTA Mock</div>}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock data
|
|
||||||
const mockEnvironmentId = "test-env-id";
|
|
||||||
const mockProjectId = "test-project-id";
|
|
||||||
const mockEnvironment = {
|
|
||||||
id: mockEnvironmentId,
|
|
||||||
name: "Test Environment",
|
|
||||||
type: "development",
|
|
||||||
} as unknown as TEnvironment;
|
|
||||||
const mockOtherEnvironment = {
|
|
||||||
id: "other-env-id",
|
|
||||||
name: "Other Environment",
|
|
||||||
type: "production",
|
|
||||||
} as unknown as TEnvironment;
|
|
||||||
const mockProject = { id: mockProjectId, name: "Test Project" } as unknown as TProject;
|
|
||||||
const mockActionClasses = [
|
|
||||||
{ id: "action1", name: "Action 1", type: "code", environmentId: mockEnvironmentId } as TActionClass,
|
|
||||||
{ id: "action2", name: "Action 2", type: "noCode", environmentId: mockEnvironmentId } as TActionClass,
|
|
||||||
];
|
|
||||||
const mockOtherEnvActionClasses = [
|
|
||||||
{ id: "action3", name: "Action 3", type: "code", environmentId: mockOtherEnvironment.id } as TActionClass,
|
|
||||||
];
|
|
||||||
const mockLocale = "en-US";
|
|
||||||
|
|
||||||
const mockParams = { environmentId: mockEnvironmentId };
|
|
||||||
const mockProps = { params: mockParams };
|
|
||||||
|
|
||||||
describe("Actions Page", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getActionClasses)
|
|
||||||
.mockResolvedValueOnce(mockActionClasses) // First call for current env
|
|
||||||
.mockResolvedValueOnce(mockOtherEnvActionClasses); // Second call for other env
|
|
||||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment, mockOtherEnvironment]);
|
|
||||||
vi.mocked(findMatchingLocale).mockResolvedValue(mockLocale);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders the page correctly with actions", async () => {
|
|
||||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
|
||||||
isReadOnly: false,
|
|
||||||
project: mockProject,
|
|
||||||
isBilling: false,
|
|
||||||
environment: mockEnvironment,
|
|
||||||
} as TEnvironmentAuth);
|
|
||||||
|
|
||||||
const PageComponent = await Page(mockProps);
|
|
||||||
render(PageComponent);
|
|
||||||
|
|
||||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // AddActionModal rendered via CTA
|
|
||||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("ActionTableHeading Mock")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("ActionClassDataRow Mock: Action 1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("ActionClassDataRow Mock: Action 2")).toBeInTheDocument();
|
|
||||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("redirects if isBilling is true", async () => {
|
|
||||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
|
||||||
isReadOnly: false,
|
|
||||||
project: mockProject,
|
|
||||||
isBilling: true,
|
|
||||||
environment: mockEnvironment,
|
|
||||||
} as TEnvironmentAuth);
|
|
||||||
|
|
||||||
await Page(mockProps);
|
|
||||||
|
|
||||||
expect(vi.mocked(redirect)).toHaveBeenCalledWith(`/environments/${mockEnvironmentId}/settings/billing`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render AddActionModal CTA if isReadOnly is true", async () => {
|
|
||||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
|
||||||
isReadOnly: true,
|
|
||||||
project: mockProject,
|
|
||||||
isBilling: false,
|
|
||||||
environment: mockEnvironment,
|
|
||||||
} as TEnvironmentAuth);
|
|
||||||
|
|
||||||
const PageComponent = await Page(mockProps);
|
|
||||||
render(PageComponent);
|
|
||||||
|
|
||||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("CTA Mock")).not.toBeInTheDocument(); // CTA should not be present
|
|
||||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders AddActionModal CTA if isReadOnly is false", async () => {
|
|
||||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
|
||||||
isReadOnly: false,
|
|
||||||
project: mockProject,
|
|
||||||
isBilling: false,
|
|
||||||
environment: mockEnvironment,
|
|
||||||
} as TEnvironmentAuth);
|
|
||||||
|
|
||||||
const PageComponent = await Page(mockProps);
|
|
||||||
render(PageComponent);
|
|
||||||
|
|
||||||
expect(screen.getByText("PageHeader Mock: common.actions")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("CTA Mock")).toBeInTheDocument(); // CTA should be present
|
|
||||||
expect(screen.getByText("ActionClassesTable Mock")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/actions/components/ActionClassesTable";
|
|
||||||
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
|
|
||||||
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
|
|
||||||
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
|
||||||
import { getActionClasses } from "@/lib/actionClass/service";
|
|
||||||
import { getEnvironments } from "@/lib/environment/service";
|
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { Metadata } from "next";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Actions",
|
|
||||||
};
|
|
||||||
|
|
||||||
const Page = async (props) => {
|
|
||||||
const params = await props.params;
|
|
||||||
|
|
||||||
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
|
|
||||||
|
|
||||||
const t = await getTranslate();
|
|
||||||
|
|
||||||
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
|
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
|
||||||
const environments = await getEnvironments(project.id);
|
|
||||||
|
|
||||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
|
||||||
|
|
||||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
|
||||||
|
|
||||||
if (isBilling) {
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderAddActionButton = () => (
|
|
||||||
<AddActionModal
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
actionClasses={actionClasses}
|
|
||||||
isReadOnly={isReadOnly}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContentWrapper>
|
|
||||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
|
||||||
<ActionClassesTable
|
|
||||||
environment={environment}
|
|
||||||
otherEnvironment={otherEnvironment}
|
|
||||||
otherEnvActionClasses={otherEnvActionClasses}
|
|
||||||
environmentId={params.environmentId}
|
|
||||||
actionClasses={actionClasses}
|
|
||||||
isReadOnly={isReadOnly}>
|
|
||||||
<ActionTableHeading />
|
|
||||||
{actionClasses.map((actionClass) => (
|
|
||||||
<ActionClassDataRow key={actionClass.id} actionClass={actionClass} locale={locale} />
|
|
||||||
))}
|
|
||||||
</ActionClassesTable>
|
|
||||||
</PageContentWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { Code2Icon, MousePointerClickIcon } from "lucide-react";
|
|
||||||
|
|
||||||
export const ACTION_TYPE_ICON_LOOKUP = {
|
|
||||||
code: <Code2Icon className="h-4 w-4" />,
|
|
||||||
noCode: <MousePointerClickIcon className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||||
|
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
@@ -5,22 +7,20 @@ import {
|
|||||||
getMonthlyActiveOrganizationPeopleCount,
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
getOrganizationsByUserId,
|
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { 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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
|
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import {
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
TOrganization,
|
|
||||||
TOrganizationBilling,
|
|
||||||
TOrganizationBillingPlanLimits,
|
|
||||||
} from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
@@ -31,16 +31,12 @@ vi.mock("@/lib/environment/service", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
getOrganizationByEnvironmentId: vi.fn(),
|
getOrganizationByEnvironmentId: vi.fn(),
|
||||||
getOrganizationsByUserId: vi.fn(),
|
|
||||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/user/service", () => ({
|
vi.mock("@/lib/user/service", () => ({
|
||||||
getUser: vi.fn(),
|
getUser: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/project/service", () => ({
|
|
||||||
getUserProjects: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
vi.mock("@/lib/membership/service", () => ({
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -49,13 +45,33 @@ vi.mock("@/lib/membership/utils", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
getOrganizationProjectsLimit: vi.fn(),
|
||||||
|
getAccessControlPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
getProjectPermissionByUserId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
||||||
|
getTeamsByOrganizationId: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/tolgee/server", () => ({
|
vi.mock("@/tolgee/server", () => ({
|
||||||
getTranslate: async () => (key: string) => key,
|
getTranslate: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
|
||||||
|
getOrganizationsByUserId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
|
||||||
|
getProjectsByUserId: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
project: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
organization: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let mockIsFormbricksCloud = false;
|
let mockIsFormbricksCloud = false;
|
||||||
let mockIsDevelopment = false;
|
let mockIsDevelopment = false;
|
||||||
@@ -71,15 +87,17 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||||
MainNavigation: () => <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", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
|
||||||
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
|
||||||
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||||
}));
|
}));
|
||||||
@@ -99,23 +117,20 @@ const mockUser = {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
imageUrl: "",
|
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
|
|
||||||
const mockOrganization = {
|
const mockOrganization = {
|
||||||
id: "org-1",
|
id: "org-1",
|
||||||
name: "Test Org",
|
name: "Test Org",
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
billing: {
|
billing: {
|
||||||
stripeCustomerId: null,
|
plan: "free",
|
||||||
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
limits: {},
|
||||||
} as unknown as TOrganizationBilling,
|
},
|
||||||
} as unknown as TOrganization;
|
} as unknown as TOrganization;
|
||||||
|
|
||||||
const mockEnvironment: TEnvironment = {
|
const mockEnvironment: TEnvironment = {
|
||||||
@@ -156,6 +171,17 @@ const mockProjectPermission = {
|
|||||||
role: "admin",
|
role: "admin",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
const mockOrganizationTeams = [
|
||||||
|
{
|
||||||
|
id: "team-1",
|
||||||
|
name: "Development Team",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team-2",
|
||||||
|
name: "Marketing Team",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const mockSession: Session = {
|
const mockSession: Session = {
|
||||||
user: {
|
user: {
|
||||||
id: "user-1",
|
id: "user-1",
|
||||||
@@ -167,15 +193,19 @@ describe("EnvironmentLayout", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
|
||||||
|
{ id: mockOrganization.id, name: mockOrganization.name },
|
||||||
|
]);
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
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(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(500);
|
||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||||
|
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
||||||
mockIsDevelopment = false;
|
mockIsDevelopment = false;
|
||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
@@ -214,33 +244,6 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders DevEnvironmentBanner in development environment", async () => {
|
|
||||||
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
|
||||||
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
|
||||||
mockIsDevelopment = true;
|
|
||||||
vi.resetModules();
|
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
|
||||||
active: false,
|
|
||||||
isPendingDowngrade: false,
|
|
||||||
features: { isMultiOrgEnabled: false },
|
|
||||||
lastChecked: new Date(),
|
|
||||||
fallbackLevel: "live",
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
const { EnvironmentLayout } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
|
||||||
);
|
|
||||||
render(
|
|
||||||
await EnvironmentLayout({
|
|
||||||
environmentId: "env-1",
|
|
||||||
session: mockSession,
|
|
||||||
children: <div>Child Content</div>,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||||
mockIsFormbricksCloud = true;
|
mockIsFormbricksCloud = true;
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -288,6 +291,84 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles empty organizationTeams array", async () => {
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles null organizationTeams", async () => {
|
||||||
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(null);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles isAccessControlAllowed false", async () => {
|
||||||
|
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
||||||
|
});
|
||||||
|
|
||||||
test("throws error if user not found", async () => {
|
test("throws error if user not found", async () => {
|
||||||
vi.mocked(getUser).mockResolvedValue(null);
|
vi.mocked(getUser).mockResolvedValue(null);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -349,7 +430,7 @@ describe("EnvironmentLayout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if projects, environments or organizations not found", async () => {
|
test("throws error if projects, environments or organizations not found", async () => {
|
||||||
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
|
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
||||||
|
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
@@ -8,14 +10,14 @@ import {
|
|||||||
getMonthlyActiveOrganizationPeopleCount,
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
getOrganizationsByUserId,
|
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { 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 { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
@@ -48,17 +50,22 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
throw new Error(t("common.environment_not_found"));
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [projects, environments] = await Promise.all([
|
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||||
getUserProjects(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),
|
getEnvironments(environment.projectId),
|
||||||
|
getAccessControlPermission(organization.billing.plan),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
if (!projects || !environments || !organizations) {
|
||||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
|
||||||
const membershipRole = currentUserMembership?.role;
|
|
||||||
const { isMember } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||||
@@ -83,10 +90,17 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
|
|
||||||
|
// Find the current project from the projects array
|
||||||
|
const project = projects.find((p) => p.id === environment.projectId);
|
||||||
|
if (!project) {
|
||||||
|
throw new Error(t("common.project_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
||||||
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||||
<DevEnvironmentBanner environment={environment} />
|
|
||||||
|
|
||||||
{IS_FORMBRICKS_CLOUD && (
|
{IS_FORMBRICKS_CLOUD && (
|
||||||
<LimitsReachedBanner
|
<LimitsReachedBanner
|
||||||
organization={organization}
|
organization={organization}
|
||||||
@@ -101,30 +115,35 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
isPendingDowngrade={isPendingDowngrade ?? false}
|
isPendingDowngrade={isPendingDowngrade ?? false}
|
||||||
active={active}
|
active={active}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
|
locale={user.locale}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
<MainNavigation
|
<MainNavigation
|
||||||
environment={environment}
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
organizations={organizations}
|
|
||||||
projects={projects}
|
projects={projects}
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
user={user}
|
user={user}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
isLicenseActive={active}
|
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
environment={environment}
|
|
||||||
environments={environments}
|
environments={environments}
|
||||||
|
currentOrganizationId={organization.id}
|
||||||
|
organizations={organizations}
|
||||||
|
currentProjectId={project.id}
|
||||||
|
projects={projects}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
isLicenseActive={active}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
projectPermission={projectPermission}
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-14">{children}</div>
|
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -7,7 +6,8 @@ import { TEnvironment } from "@formbricks/types/environment";
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
import { MainNavigation } from "./MainNavigation";
|
import { MainNavigation } from "./MainNavigation";
|
||||||
|
|
||||||
// Mock constants that this test needs
|
// Mock constants that this test needs
|
||||||
@@ -32,7 +32,7 @@ vi.mock("next-auth/react", () => ({
|
|||||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||||
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
useSignOut: vi.fn(() => ({ signOut: vi.fn() })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
vi.mock("@/modules/projects/settings/(setup)/app-connection/actions", () => ({
|
||||||
getLatestStableFbReleaseAction: vi.fn(),
|
getLatestStableFbReleaseAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/lib/formbricks", () => ({
|
vi.mock("@/app/lib/formbricks", () => ({
|
||||||
@@ -51,13 +51,6 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|||||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
|
||||||
ProjectSwitcher: ({ isCollapsed }: { isCollapsed: boolean }) => (
|
|
||||||
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
|
||||||
Project Switcher
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||||
}));
|
}));
|
||||||
@@ -100,13 +93,12 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
imageUrl: "http://example.com/avatar.png",
|
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
notificationSettings: { alert: {}, weeklySummary: {} },
|
notificationSettings: { alert: {} },
|
||||||
role: "project_manager",
|
role: "project_manager",
|
||||||
objective: "other",
|
objective: "other",
|
||||||
} as unknown as TUser;
|
} as unknown as TUser;
|
||||||
@@ -146,6 +138,7 @@ const defaultProps = {
|
|||||||
membershipRole: "owner" as const,
|
membershipRole: "owner" as const,
|
||||||
organizationProjectsLimit: 5,
|
organizationProjectsLimit: 5,
|
||||||
isLicenseActive: true,
|
isLicenseActive: true,
|
||||||
|
isAccessControlAllowed: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
describe("MainNavigation", () => {
|
||||||
@@ -166,13 +159,11 @@ describe("MainNavigation", () => {
|
|||||||
|
|
||||||
test("renders expanded by default and collapses on toggle", async () => {
|
test("renders expanded by default and collapses on toggle", async () => {
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
const projectSwitcher = screen.getByTestId("project-switcher");
|
|
||||||
// Assuming the toggle button is the only one initially without an accessible name
|
// Assuming the toggle button is the only one initially without an accessible name
|
||||||
// A more specific selector like data-testid would be better if available.
|
// A more specific selector like data-testid would be better if available.
|
||||||
const toggleButton = screen.getByRole("button", { name: "" });
|
const toggleButton = screen.getByRole("button", { name: "" });
|
||||||
|
|
||||||
// Check initial state (expanded)
|
// Check initial state (expanded)
|
||||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||||
// Check localStorage is not set initially after clear()
|
// Check localStorage is not set initially after clear()
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||||
@@ -183,7 +174,6 @@ describe("MainNavigation", () => {
|
|||||||
// Check state after first toggle (collapsed)
|
// Check state after first toggle (collapsed)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check that the attribute eventually becomes true
|
// Check that the attribute eventually becomes true
|
||||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
|
||||||
// Check that localStorage is updated
|
// Check that localStorage is updated
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||||
});
|
});
|
||||||
@@ -198,7 +188,6 @@ describe("MainNavigation", () => {
|
|||||||
// Check state after second toggle (expanded)
|
// Check state after second toggle (expanded)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check that the attribute eventually becomes false
|
// Check that the attribute eventually becomes false
|
||||||
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
|
||||||
// Check that localStorage is updated
|
// Check that localStorage is updated
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||||
});
|
});
|
||||||
@@ -208,14 +197,6 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders correct active navigation link", () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env1/actions");
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
const actionsLink = screen.getByRole("link", { name: /common.actions/ });
|
|
||||||
// Check if the parent li has the active class styling
|
|
||||||
expect(actionsLink.closest("li")).toHaveClass("border-brand-dark");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders user dropdown and handles logout", async () => {
|
test("renders user dropdown and handles logout", async () => {
|
||||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||||
@@ -229,13 +210,12 @@ describe("MainNavigation", () => {
|
|||||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
||||||
await userEvent.click(userTrigger);
|
await userEvent.click(userTrigger);
|
||||||
|
|
||||||
// Wait for the dropdown content to appear
|
// Wait for the dropdown content to appear - using getAllByText to handle multiple instances
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
const accountElements = screen.getAllByText("common.account");
|
||||||
|
expect(accountElements).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
|
||||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -256,46 +236,6 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles organization switching", async () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
|
||||||
await userEvent.click(userTrigger);
|
|
||||||
|
|
||||||
// Wait for the initial dropdown items
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
|
||||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
|
||||||
|
|
||||||
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
|
||||||
await userEvent.click(org2Item);
|
|
||||||
|
|
||||||
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens create organization modal", async () => {
|
|
||||||
render(<MainNavigation {...defaultProps} />);
|
|
||||||
|
|
||||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
|
||||||
await userEvent.click(userTrigger);
|
|
||||||
|
|
||||||
// Wait for the initial dropdown items
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
|
||||||
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
|
||||||
|
|
||||||
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
|
||||||
await userEvent.click(createOrgButton);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides new version banner for members or if no new version", async () => {
|
test("hides new version banner for members or if no new version", async () => {
|
||||||
// Test for member
|
// Test for member
|
||||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||||
@@ -323,15 +263,25 @@ describe("MainNavigation", () => {
|
|||||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows billing link and hides license link in cloud", async () => {
|
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
||||||
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
|
||||||
await userEvent.click(userTrigger);
|
|
||||||
|
|
||||||
// Wait for dropdown items
|
// Test basic navigation structure is rendered (aside element with complementary role)
|
||||||
await waitFor(() => {
|
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
|
||||||
|
test("handles no organizationTeams", () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
// Test that navigation renders correctly with no teams
|
||||||
|
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles isAccessControlAllowed false", () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
// Test that navigation renders correctly with access control disabled
|
||||||
|
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
|
||||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
BlocksIcon,
|
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
CreditCardIcon,
|
|
||||||
KeyIcon,
|
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
MousePointerClick,
|
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
PlusIcon,
|
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
UsersIcon,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -50,53 +20,55 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
|
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||||
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
organizations: TOrganization[];
|
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
projects: TProject[];
|
projects: { id: string; name: string }[];
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organizations,
|
|
||||||
organization,
|
organization,
|
||||||
user,
|
user,
|
||||||
projects,
|
projects,
|
||||||
isMultiOrgEnabled,
|
|
||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
organizationProjectsLimit,
|
|
||||||
isLicenseActive,
|
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
|
|
||||||
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
|
||||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const project = projects.find((project) => project.id === environment.projectId);
|
const project = projects.find((project) => project.id === environment.projectId);
|
||||||
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
const isPricingDisabled = isMember;
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
@@ -117,40 +89,11 @@ export const MainNavigation = ({
|
|||||||
}, [isCollapsed]);
|
}, [isCollapsed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (organization && organization.name !== "") {
|
// Auto collapse project navbar on org and account settings
|
||||||
setCurrentOrganizationName(organization.name);
|
if (pathname?.includes("/settings")) {
|
||||||
setCurrentOrganizationId(organization.id);
|
setIsCollapsed(true);
|
||||||
}
|
}
|
||||||
}, [organization]);
|
}, [pathname]);
|
||||||
|
|
||||||
const sortedOrganizations = useMemo(() => {
|
|
||||||
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
}, [organizations]);
|
|
||||||
|
|
||||||
const sortedProjects = useMemo(() => {
|
|
||||||
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
|
||||||
|
|
||||||
const groupedProjects = projects.reduce(
|
|
||||||
(acc, project) => {
|
|
||||||
const channel = project.config.channel;
|
|
||||||
const key = channel !== null ? channel : "null";
|
|
||||||
acc[key] = acc[key] || [];
|
|
||||||
acc[key].push(project);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, typeof projects>
|
|
||||||
);
|
|
||||||
|
|
||||||
Object.keys(groupedProjects).forEach((channel) => {
|
|
||||||
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
});
|
|
||||||
|
|
||||||
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
|
|
||||||
}, [projects]);
|
|
||||||
|
|
||||||
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const mainNavigation = useMemo(
|
const mainNavigation = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -167,18 +110,6 @@ export const MainNavigation = ({
|
|||||||
icon: UserIcon,
|
icon: UserIcon,
|
||||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: t("common.actions"),
|
|
||||||
href: `/environments/${environment.id}/actions`,
|
|
||||||
icon: MousePointerClick,
|
|
||||||
isActive: pathname?.includes("/actions"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: t("common.integrations"),
|
|
||||||
href: `/environments/${environment.id}/integrations`,
|
|
||||||
icon: BlocksIcon,
|
|
||||||
isActive: pathname?.includes("/integrations"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/project/general`,
|
href: `/environments/${environment.id}/project/general`,
|
||||||
@@ -195,29 +126,18 @@ export const MainNavigation = ({
|
|||||||
href: `/environments/${environment.id}/settings/profile`,
|
href: `/environments/${environment.id}/settings/profile`,
|
||||||
icon: UserCircleIcon,
|
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"),
|
label: t("common.documentation"),
|
||||||
href: "https://formbricks.com/docs",
|
href: "https://formbricks.com/docs",
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
icon: ArrowUpRightIcon,
|
icon: ArrowUpRightIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: t("common.share_feedback"),
|
||||||
|
href: "https://github.com/formbricks/formbricks/issues",
|
||||||
|
target: "_blank",
|
||||||
|
icon: ArrowUpRightIcon,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -227,7 +147,7 @@ export const MainNavigation = ({
|
|||||||
const latestVersionTag = res.data;
|
const latestVersionTag = res.data;
|
||||||
const currentVersionTag = `v${packageJson.version}`;
|
const currentVersionTag = `v${packageJson.version}`;
|
||||||
|
|
||||||
if (currentVersionTag !== latestVersionTag) {
|
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
||||||
setLatestVersion(latestVersionTag);
|
setLatestVersion(latestVersionTag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,8 +163,7 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||||
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -310,22 +229,6 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Project Switch */}
|
|
||||||
{!isBilling && (
|
|
||||||
<ProjectSwitcher
|
|
||||||
environmentId={environment.id}
|
|
||||||
projects={sortedProjects}
|
|
||||||
project={project}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isLicenseActive={isLicenseActive}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
isTextVisible={isTextVisible}
|
|
||||||
organization={organization}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* User Switch */}
|
{/* User Switch */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -334,29 +237,27 @@ export const MainNavigation = ({
|
|||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-row items-center space-x-3",
|
"flex cursor-pointer flex-row items-center gap-3",
|
||||||
isCollapsed ? "pl-2" : "pl-4"
|
isCollapsed ? "justify-center px-2" : "px-4"
|
||||||
)}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
<ProfileAvatar userId={user.id} />
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
<div
|
||||||
|
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 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>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
||||||
title={capitalizeFirstLetter(organization?.name)}
|
|
||||||
className="max-w-28 truncate text-sm text-slate-500">
|
|
||||||
{capitalizeFirstLetter(organization?.name)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
@@ -370,24 +271,20 @@ export const MainNavigation = ({
|
|||||||
align="end">
|
align="end">
|
||||||
{/* Dropdown Items */}
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map(
|
{dropdownNavigation.map((link) => (
|
||||||
(link) =>
|
<Link
|
||||||
!link.hidden && (
|
href={link.href}
|
||||||
<Link
|
target={link.target}
|
||||||
href={link.href}
|
className="flex w-full items-center"
|
||||||
target={link.target}
|
key={link.label}
|
||||||
className="flex w-full items-center"
|
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
||||||
key={link.label}>
|
<DropdownMenuItem>
|
||||||
<DropdownMenuItem>
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
{link.label}
|
||||||
{link.label}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
</Link>
|
||||||
</Link>
|
))}
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const route = await signOutWithAudit({
|
const route = await signOutWithAudit({
|
||||||
@@ -403,55 +300,12 @@ export const MainNavigation = ({
|
|||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
{/* Organization Switch */}
|
|
||||||
|
|
||||||
{(isMultiOrgEnabled || organizations.length > 1) && (
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p>{currentOrganizationName}</p>
|
|
||||||
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
|
||||||
<DropdownMenuRadioGroup
|
|
||||||
value={currentOrganizationId}
|
|
||||||
onValueChange={(organizationId) =>
|
|
||||||
handleEnvironmentChangeByOrganization(organizationId)
|
|
||||||
}>
|
|
||||||
{sortedOrganizations.map((organization) => (
|
|
||||||
<DropdownMenuRadioItem
|
|
||||||
value={organization.id}
|
|
||||||
className="cursor-pointer rounded-lg"
|
|
||||||
key={organization.id}>
|
|
||||||
{organization.name}
|
|
||||||
</DropdownMenuRadioItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuRadioGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setShowCreateOrganizationModal(true)}
|
|
||||||
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
|
||||||
<span>{t("common.create_new_organization")}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
<CreateOrganizationModal
|
|
||||||
open={showCreateOrganizationModal}
|
|
||||||
setOpen={(val) => setShowCreateOrganizationModal(val)}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,8 +36,6 @@ describe("PosthogIdentify", () => {
|
|||||||
{
|
{
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
role: "engineer",
|
|
||||||
objective: "increase_conversion",
|
|
||||||
} as TUser
|
} as TUser
|
||||||
}
|
}
|
||||||
environmentId="env-456"
|
environmentId="env-456"
|
||||||
@@ -57,8 +55,6 @@ describe("PosthogIdentify", () => {
|
|||||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
role: "engineer",
|
|
||||||
objective: "increase_conversion",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// environment + organization groups
|
// environment + organization groups
|
||||||
@@ -142,8 +138,6 @@ describe("PosthogIdentify", () => {
|
|||||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
role: undefined,
|
|
||||||
objective: undefined,
|
|
||||||
});
|
});
|
||||||
// No environmentId or organizationId => no group calls
|
// No environmentId or organizationId => no group calls
|
||||||
expect(mockGroup).not.toHaveBeenCalled();
|
expect(mockGroup).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ export const PosthogIdentify = ({
|
|||||||
posthog.identify(session.user.id, {
|
posthog.identify(session.user.id, {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
|
||||||
objective: user.objective,
|
|
||||||
});
|
});
|
||||||
if (environmentId) {
|
if (environmentId) {
|
||||||
posthog.group("environment", environmentId, { name: environmentId });
|
posthog.group("environment", environmentId, { name: environmentId });
|
||||||
@@ -56,8 +54,6 @@ export const PosthogIdentify = ({
|
|||||||
organizationBilling,
|
organizationBilling,
|
||||||
user.name,
|
user.name,
|
||||||
user.email,
|
user.email,
|
||||||
user.role,
|
|
||||||
user.objective,
|
|
||||||
isPosthogEnabled,
|
isPosthogEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const TestComponent = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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="filterLength">{selectedFilter.filter.length}</div>
|
||||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||||
@@ -44,7 +44,7 @@ const TestComponent = () => {
|
|||||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
onlyComplete: true,
|
responseStatus: "complete",
|
||||||
})
|
})
|
||||||
}>
|
}>
|
||||||
Update Filter
|
Update Filter
|
||||||
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
</ResponseFilterProvider>
|
</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("filterLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||||
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
const updateButton = screen.getByText("Update Filter");
|
const updateButton = screen.getByText("Update Filter");
|
||||||
await userEvent.click(updateButton);
|
await userEvent.click(updateButton);
|
||||||
|
|
||||||
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ export interface FilterValue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TResponseStatus = "all" | "complete" | "partial";
|
||||||
|
|
||||||
export interface SelectedFilterValue {
|
export interface SelectedFilterValue {
|
||||||
filter: FilterValue[];
|
filter: FilterValue[];
|
||||||
onlyComplete: boolean;
|
responseStatus: TResponseStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedFilterOptions {
|
interface SelectedFilterOptions {
|
||||||
@@ -47,7 +49,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
// state holds the filter selected value
|
// state holds the filter selected value
|
||||||
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
||||||
filter: [],
|
filter: [],
|
||||||
onlyComplete: false,
|
responseStatus: "all",
|
||||||
});
|
});
|
||||||
// state holds all the options of the responses fetched
|
// state holds all the options of the responses fetched
|
||||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||||
@@ -67,7 +69,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
setSelectedFilter({
|
setSelectedFilter({
|
||||||
filter: [],
|
filter: [],
|
||||||
onlyComplete: false,
|
responseStatus: "all",
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
|
||||||
import { TopControlBar } from "./TopControlBar";
|
|
||||||
|
|
||||||
// Mock the child component
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
|
||||||
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const mockEnvironment: TEnvironment = {
|
|
||||||
id: "env1",
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironments: TEnvironment[] = [
|
|
||||||
mockEnvironment,
|
|
||||||
{ ...mockEnvironment, id: "env2", type: "development" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockMembershipRole: TOrganizationRole = "owner";
|
|
||||||
const mockProjectPermission = "manage";
|
|
||||||
|
|
||||||
describe("TopControlBar", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders correctly and passes props to TopControlButtons", () => {
|
|
||||||
render(
|
|
||||||
<TopControlBar
|
|
||||||
environment={mockEnvironment}
|
|
||||||
environments={mockEnvironments}
|
|
||||||
membershipRole={mockMembershipRole}
|
|
||||||
projectPermission={mockProjectPermission}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the main div is rendered
|
|
||||||
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
|
|
||||||
expect(mainDiv).toHaveClass(
|
|
||||||
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if the mocked child component is rendered
|
|
||||||
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check if the child component received the correct props
|
|
||||||
expect(TopControlButtons).toHaveBeenCalledWith(
|
|
||||||
{
|
|
||||||
environment: mockEnvironment,
|
|
||||||
environments: mockEnvironments,
|
|
||||||
membershipRole: mockMembershipRole,
|
|
||||||
projectPermission: mockProjectPermission,
|
|
||||||
},
|
|
||||||
undefined // Updated from {} to undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,33 +1,62 @@
|
|||||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
"use client";
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
|
||||||
|
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||||
|
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
interface SideBarProps {
|
interface TopControlBarProps {
|
||||||
environment: TEnvironment;
|
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
|
currentOrganizationId: string;
|
||||||
|
organizations: { id: string; name: string }[];
|
||||||
|
currentProjectId: string;
|
||||||
|
projects: { id: string; name: string }[];
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
|
organizationProjectsLimit: number;
|
||||||
|
isFormbricksCloud: boolean;
|
||||||
|
isLicenseActive: boolean;
|
||||||
|
isOwnerOrManager: boolean;
|
||||||
|
isAccessControlAllowed: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
projectPermission: TTeamPermission | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
environment,
|
|
||||||
environments,
|
environments,
|
||||||
|
currentOrganizationId,
|
||||||
|
organizations,
|
||||||
|
currentProjectId,
|
||||||
|
projects,
|
||||||
|
isMultiOrgEnabled,
|
||||||
|
organizationProjectsLimit,
|
||||||
|
isFormbricksCloud,
|
||||||
|
isLicenseActive,
|
||||||
|
isOwnerOrManager,
|
||||||
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
projectPermission,
|
}: TopControlBarProps) => {
|
||||||
}: SideBarProps) => {
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
|
const { environment } = useEnvironment();
|
||||||
|
|
||||||
return (
|
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
|
||||||
<div className="shadow-xs z-10">
|
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||||
<div className="flex w-fit items-center space-x-2 py-2">
|
data-testid="fb__global-top-control-bar">
|
||||||
<TopControlButtons
|
<ProjectAndOrgSwitch
|
||||||
environment={environment}
|
currentEnvironmentId={environment.id}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
membershipRole={membershipRole}
|
currentOrganizationId={currentOrganizationId}
|
||||||
projectPermission={projectPermission}
|
organizations={organizations}
|
||||||
/>
|
currentProjectId={currentProjectId}
|
||||||
</div>
|
projects={projects}
|
||||||
</div>
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isLicenseActive={isLicenseActive}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
isMember={isMember}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
|
/>
|
||||||
</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,329 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the UI components
|
||||||
|
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
||||||
|
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
|
||||||
|
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
|
||||||
|
{children}
|
||||||
|
</li>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||||
|
DropdownMenu: ({ children, onOpenChange }: any) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="dropdown-menu"
|
||||||
|
onClick={() => onOpenChange?.(true)}
|
||||||
|
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuContent: ({ children, ...props }: any) => (
|
||||||
|
<div data-testid="dropdown-content" {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
||||||
|
<div
|
||||||
|
data-testid="dropdown-checkbox-item"
|
||||||
|
data-checked={checked}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
tabIndex={0}
|
||||||
|
{...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
||||||
|
<button data-testid="dropdown-trigger" {...props}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||||
|
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
|
||||||
|
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||||
|
TooltipTrigger: ({ children, asChild }: any) => (
|
||||||
|
<div data-testid="tooltip-trigger" data-as-child={asChild}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
TooltipContent: ({ children, className }: any) => (
|
||||||
|
<div data-testid="tooltip-content" className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Lucide React icons
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
Code2Icon: ({ className, strokeWidth }: any) => {
|
||||||
|
const isHeader = className?.includes("mr-2");
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
|
||||||
|
className={className}
|
||||||
|
strokeWidth={strokeWidth}>
|
||||||
|
<title>Code2 Icon</title>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
||||||
|
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
||||||
|
<title>ChevronDown Icon</title>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
CircleHelpIcon: ({ className }: any) => (
|
||||||
|
<svg data-testid="circle-help-icon" className={className}>
|
||||||
|
<title>CircleHelp Icon</title>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
Loader2: ({ className }: any) => (
|
||||||
|
<svg data-testid="loader-2-icon" className={className}>
|
||||||
|
<title>Loader2 Icon</title>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("EnvironmentBreadcrumb", () => {
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
const mockRouter = {
|
||||||
|
push: mockPush,
|
||||||
|
replace: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
forward: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProductionEnvironment: TEnvironment = {
|
||||||
|
id: "env-prod-1",
|
||||||
|
createdAt: new Date("2023-01-01"),
|
||||||
|
updatedAt: new Date("2023-01-01"),
|
||||||
|
type: "production",
|
||||||
|
projectId: "project-1",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockDevelopmentEnvironment: TEnvironment = {
|
||||||
|
id: "env-dev-1",
|
||||||
|
createdAt: new Date("2023-01-01"),
|
||||||
|
updatedAt: new Date("2023-01-01"),
|
||||||
|
type: "development",
|
||||||
|
projectId: "project-1",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders environment breadcrumb with production environment", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders environment breadcrumb with development environment and shows tooltip", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb
|
||||||
|
environments={mockEnvironments}
|
||||||
|
currentEnvironment={mockDevelopmentEnvironment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
|
||||||
|
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("highlights breadcrumb item for development environment", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb
|
||||||
|
environments={mockEnvironments}
|
||||||
|
currentEnvironment={mockDevelopmentEnvironment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||||
|
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not highlight breadcrumb item for production environment", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||||
|
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows chevron down icon when dropdown is open", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||||
|
await user.click(dropdownMenu);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders dropdown content with environment options", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||||
|
await user.click(dropdownMenu);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders all environment options in dropdown", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||||
|
await user.click(dropdownMenu);
|
||||||
|
|
||||||
|
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||||
|
expect(checkboxItems).toHaveLength(2);
|
||||||
|
|
||||||
|
// Check production environment option
|
||||||
|
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
|
||||||
|
expect(productionOption).toBeInTheDocument();
|
||||||
|
expect(productionOption).toHaveAttribute("data-checked", "true");
|
||||||
|
|
||||||
|
// Check development environment option
|
||||||
|
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||||
|
expect(developmentOption).toBeInTheDocument();
|
||||||
|
expect(developmentOption).toHaveAttribute("data-checked", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles environment change when clicking dropdown option", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||||
|
await user.click(dropdownMenu);
|
||||||
|
|
||||||
|
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
||||||
|
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
||||||
|
|
||||||
|
expect(developmentOption).toBeInTheDocument();
|
||||||
|
await user.click(developmentOption!);
|
||||||
|
|
||||||
|
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("capitalizes environment type in display", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const environmentSpans = screen.getAllByText("production");
|
||||||
|
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
|
||||||
|
expect(triggerSpan).toHaveClass("capitalize");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tooltip shows correct content for development environment", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb
|
||||||
|
environments={mockEnvironments}
|
||||||
|
currentEnvironment={mockDevelopmentEnvironment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooltipContent = screen.getByTestId("tooltip-content");
|
||||||
|
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
|
||||||
|
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders without tooltip for production environment", () => {
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initially not active
|
||||||
|
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||||
|
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
||||||
|
|
||||||
|
// Open dropdown
|
||||||
|
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||||
|
await user.click(dropdownMenu);
|
||||||
|
|
||||||
|
// Should be active when dropdown is open
|
||||||
|
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||||
|
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles single environment scenario", () => {
|
||||||
|
const singleEnvironment = [mockProductionEnvironment];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<EnvironmentBreadcrumb
|
||||||
|
environments={singleEnvironment}
|
||||||
|
currentEnvironment={mockProductionEnvironment}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty environments array gracefully", () => {
|
||||||
|
render(<EnvironmentBreadcrumb environments={[]} currentEnvironment={mockProductionEnvironment} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("production")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user