mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 06:30:51 -06:00
Compare commits
88 Commits
pr-6518-du
...
fix-survey
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f05f85e9 | ||
|
|
74405cc05f | ||
|
|
785359955a | ||
|
|
f6157d5109 | ||
|
|
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 |
@@ -16,9 +16,10 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
||||
## Key Files
|
||||
|
||||
### Core Cache Infrastructure
|
||||
- [apps/web/modules/cache/lib/service.ts](mdc:apps/web/modules/cache/lib/service.ts) - Redis cache service
|
||||
- [apps/web/modules/cache/lib/withCache.ts](mdc:apps/web/modules/cache/lib/withCache.ts) - Cache wrapper utilities
|
||||
- [apps/web/modules/cache/lib/cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts) - Enterprise cache key patterns and utilities
|
||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
||||
|
||||
### Environment State Caching (Critical Endpoint)
|
||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
||||
@@ -26,7 +27,7 @@ Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
||||
|
||||
## Enterprise-Grade Cache Key Patterns
|
||||
|
||||
**Always use** the `createCacheKey` utilities from [cacheKeys.ts](mdc:apps/web/modules/cache/lib/cacheKeys.ts):
|
||||
**Always use** the `createCacheKey` utilities from the cache package:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct patterns
|
||||
@@ -50,14 +51,14 @@ export const getEnterpriseLicense = reactCache(async () => {
|
||||
});
|
||||
```
|
||||
|
||||
### Use `withCache()` for Simple Database Queries
|
||||
### Use `cache.withCache()` for Simple Database Queries
|
||||
```typescript
|
||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
||||
export const getActionClasses = (environmentId: string) => {
|
||||
return withCache(() => fetchActionClassesFromDB(environmentId), {
|
||||
key: createCacheKey.environment.actionClasses(environmentId),
|
||||
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
|
||||
})();
|
||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
||||
createCacheKey.environment.actionClasses(environmentId),
|
||||
60 * 30 * 1000 // 30 minutes in milliseconds
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
---
|
||||
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
alwaysApply: false
|
||||
description: >
|
||||
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||
globs: []
|
||||
alwaysApply: agent-requested
|
||||
---
|
||||
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
@@ -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
|
||||
@@ -1,74 +0,0 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
### Formbricks Monorepo Overview
|
||||
|
||||
- **Project**: Formbricks — open‑source survey and experience management platform. Repo: [formbricks/formbricks](https://github.com/formbricks/formbricks)
|
||||
- **Monorepo**: Turborepo + pnpm workspaces. Root configs: [package.json](mdc:package.json), [turbo.json](mdc:turbo.json)
|
||||
- **Core app**: Next.js app in `apps/web` with Prisma, Auth.js, TailwindCSS, Vitest, Playwright. Enterprise modules live in [apps/web/modules/ee](mdc:apps/web/modules/ee)
|
||||
- **Datastores**: PostgreSQL + Redis. Local dev via [docker-compose.dev.yml](mdc:docker-compose.dev.yml); Prisma schema at [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- **Docs & Ops**: Docs in `docs/` (Mintlify), Helm in `helm-chart/`, IaC in `infra/`
|
||||
|
||||
### Apps
|
||||
|
||||
- **apps/web**: Next.js product application (API, UI, SSO, i18n, emails, uploads, integrations)
|
||||
- **apps/storybook**: Storybook for UI components; a11y addon + Vite builder
|
||||
|
||||
### Packages
|
||||
|
||||
- **@formbricks/database** (`packages/database`): Prisma schema, DB scripts, migrations, data layer
|
||||
- **@formbricks/js-core** (`packages/js-core`): Core runtime for web embed / async loader
|
||||
- **@formbricks/surveys** (`packages/surveys`): Embeddable survey rendering and helpers
|
||||
- **@formbricks/logger** (`packages/logger`): Shared logging (pino) + Zod types
|
||||
- **@formbricks/types** (`packages/types`): Shared types (Zod, Prisma clients)
|
||||
- **@formbricks/i18n-utils** (`packages/i18n-utils`): i18n helpers and build output
|
||||
- **@formbricks/eslint-config** (`packages/config-eslint`): Central ESLint config (Next, TS, Vitest, Prettier)
|
||||
- **@formbricks/config-typescript** (`packages/config-typescript`): Central TS config and types
|
||||
- **@formbricks/vite-plugins** (`packages/vite-plugins`): Internal Vite plugins
|
||||
- **packages/android, packages/ios**: Native SDKs (built with platform toolchains)
|
||||
|
||||
### Enterprise‑ready by design
|
||||
|
||||
- **Quality & safety**: Strict TypeScript, repo‑wide ESLint + Prettier, lint‑staged + Husky, CI checks, typed env validation
|
||||
- **Security‑first**: Auth.js, SSO/SAML/OIDC, session controls, rate limiting, Sentry, structured logging
|
||||
|
||||
### Accessible by design
|
||||
|
||||
- **UI foundations**: Radix UI, TailwindCSS, Storybook with `@storybook/addon-a11y`, keyboard and screen‑reader‑friendly components
|
||||
|
||||
### Root pnpm commands
|
||||
|
||||
```bash
|
||||
pnpm clean:all # Clean turbo cache, node_modules, lockfile, coverage, out
|
||||
pnpm clean # Clean turbo cache, node_modules, coverage, out
|
||||
pnpm build # Build all packages/apps (turbo)
|
||||
pnpm build:dev # Dev-optimized builds (where supported)
|
||||
pnpm dev # Run all dev servers in parallel
|
||||
pnpm start # Start built apps/services
|
||||
pnpm go # Start DB (docker compose) and run long-running dev tasks
|
||||
pnpm generate # Run generators (e.g., Prisma, API specs)
|
||||
pnpm lint # Lint all
|
||||
pnpm format # Prettier write across repo
|
||||
pnpm test # Unit tests
|
||||
pnpm test:coverage # Unit tests with coverage
|
||||
pnpm test:e2e # Playwright tests
|
||||
pnpm test-e2e:azure # Playwright tests with Azure config
|
||||
pnpm storybook # Run Storybook
|
||||
pnpm db:up # Start local Postgres/Redis via docker compose
|
||||
pnpm db:down # Stop local DB stack
|
||||
pnpm db:start # Project-level DB setup choreography
|
||||
pnpm db:push # Prisma db push (accept data loss in package script)
|
||||
pnpm db:migrate:dev # Apply dev migrations
|
||||
pnpm db:migrate:deploy # Apply prod migrations
|
||||
pnpm fb-migrate-dev # Create DB migration (database package) and prisma generate
|
||||
pnpm tolgee-pull # Pull translation keys for current branch and format
|
||||
```
|
||||
|
||||
### Essentials for every prompt
|
||||
|
||||
- **Tech stack**: Next.js, React 19, TypeScript, Prisma, Zod, TailwindCSS, Turborepo, Vitest, Playwright
|
||||
- **Environments**: See `.env.example`. Many tasks require DB up and env variables set
|
||||
- **Licensing**: Core under AGPLv3; Enterprise code in `apps/web/modules/ee` (included in Docker, unlocked via Enterprise License Key)
|
||||
|
||||
For deeper details, consult per‑package `package.json` and scripts (e.g., [apps/web/package.json](mdc:apps/web/package.json)).
|
||||
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.**
|
||||
@@ -62,9 +62,6 @@ SMTP_PASSWORD=smtpPassword
|
||||
|
||||
# Uncomment the variables you would like to use and customize the values.
|
||||
|
||||
# Custom local storage path for file uploads
|
||||
#UPLOADS_DIR=
|
||||
|
||||
##############
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
@@ -99,8 +96,6 @@ PASSWORD_RESET_DISABLED=1
|
||||
# Organization Invite. Disable the ability for invited users to create an account.
|
||||
# INVITE_DISABLED=1
|
||||
|
||||
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
|
||||
# DOCKER_CRON_ENABLED=1
|
||||
|
||||
##########
|
||||
# Other #
|
||||
|
||||
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",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
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
|
||||
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
|
||||
182
.github/workflows/build-and-push-ecr.yml
vendored
182
.github/workflows/build-and-push-ecr.yml
vendored
@@ -1,12 +1,16 @@
|
||||
name: Build & Push Docker to ECR
|
||||
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:
|
||||
image_tag:
|
||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
||||
required: true
|
||||
default: "v3.16.1"
|
||||
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
|
||||
@@ -17,6 +21,29 @@ on:
|
||||
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
|
||||
@@ -27,14 +54,15 @@ env:
|
||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
||||
DOCKERFILE: apps/web/Dockerfile
|
||||
CONTEXT: .
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and Push
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
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
|
||||
@@ -44,125 +72,23 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Validate image tag input
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||
echo "❌ Image tag is required (non-empty)."
|
||||
exit 1
|
||||
fi
|
||||
if (( ${#IMAGE_TAG} > 128 )); then
|
||||
echo "❌ Image tag must be at most 128 characters."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
||||
exit 1
|
||||
fi
|
||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate required variables
|
||||
shell: bash
|
||||
env:
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
ECR_REGION: ${{ env.ECR_REGION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Update package.json version
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
|
||||
VERSION="${IMAGE_TAG#v}"
|
||||
|
||||
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
|
||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
|
||||
echo "Original input: ${IMAGE_TAG}"
|
||||
echo "Extracted version: ${VERSION}"
|
||||
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Valid SemVer format detected: ${VERSION}"
|
||||
echo "Updating package.json version to: ${VERSION}"
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Build tag list
|
||||
id: tags
|
||||
shell: bash
|
||||
env:
|
||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
||||
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
||||
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Start with the base image tag
|
||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
||||
|
||||
# Add production tag if requested
|
||||
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
||||
fi
|
||||
|
||||
# Add staging tag if requested
|
||||
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
||||
fi
|
||||
|
||||
# Output for debugging
|
||||
echo "Generated tags:"
|
||||
echo -e "${TAGS}"
|
||||
|
||||
# Set output for next step (escape newlines for GitHub Actions)
|
||||
{
|
||||
echo "tags<<EOF"
|
||||
echo -e "${TAGS}"
|
||||
echo "EOF"
|
||||
} >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Configure AWS credentials (OIDC)
|
||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
||||
- name: Build and push cloud deployment image
|
||||
id: build
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
||||
aws-region: ${{ env.ECR_REGION }}
|
||||
|
||||
- name: Log in to Amazon ECR
|
||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
- name: Build and push image (Depot remote builder)
|
||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
||||
context: ${{ env.CONTEXT }}
|
||||
file: ${{ env.DOCKERFILE }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
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,7 +4,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
VERSION:
|
||||
description: "The version of the Docker image to release, full image tag if image tag is v0.0.0 enter v0.0.0."
|
||||
description: "The version of the Docker image to release (clean SemVer, e.g., 1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
REPOSITORY:
|
||||
|
||||
34
.github/workflows/docker-build-validation.yml
vendored
34
.github/workflows/docker-build-validation.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
name: Validate Docker Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Add PostgreSQL service container
|
||||
# Add PostgreSQL and Redis service containers
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||
env:
|
||||
POSTGRES_USER: test
|
||||
POSTGRES_PASSWORD: test
|
||||
@@ -38,6 +38,11 @@ jobs:
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
@@ -67,6 +72,7 @@ jobs:
|
||||
secrets: |
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
redis_url=redis://localhost:6379
|
||||
|
||||
- name: Verify and Initialize PostgreSQL
|
||||
run: |
|
||||
@@ -96,6 +102,29 @@ jobs:
|
||||
echo "Network configuration:"
|
||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||
|
||||
- name: Verify Redis/Valkey Connection
|
||||
run: |
|
||||
echo "Verifying Redis/Valkey connection..."
|
||||
# Install Redis client to test connection
|
||||
sudo apt-get update && sudo apt-get install -y redis-tools
|
||||
|
||||
# Test connection using redis-cli with timeout and proper error handling
|
||||
echo "Testing Redis connection with 30 second timeout..."
|
||||
if timeout 30 bash -c 'until redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; do
|
||||
echo "Waiting for Redis to be ready..."
|
||||
sleep 2
|
||||
done'; then
|
||||
echo "✅ Redis connection successful"
|
||||
redis-cli -h localhost -p 6379 info server | head -5
|
||||
else
|
||||
echo "❌ Redis connection failed after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Show network configuration for Redis
|
||||
echo "Redis network configuration:"
|
||||
netstat -tulpn | grep 6379 || echo "No process listening on port 6379"
|
||||
|
||||
- name: Test Docker Image with Health Check
|
||||
shell: bash
|
||||
env:
|
||||
@@ -113,6 +142,7 @@ jobs:
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
||||
-e REDIS_URL="redis://host.docker.internal:6379" \
|
||||
-d "formbricks-test:$GITHUB_SHA"
|
||||
|
||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
||||
|
||||
94
.github/workflows/e2e.yml
vendored
94
.github/workflows/e2e.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
image: pgvector/pgvector@sha256:9ae02a756ba16a2d69dd78058e25915e36e189bb36ddf01ceae86390d7ed786a
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
@@ -41,27 +41,23 @@ jobs:
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U testuser"
|
||||
--health-cmd="pg_isready -U postgres"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
valkey:
|
||||
image: valkey/valkey:8.1.1
|
||||
image: valkey/valkey@sha256:12ba4f45a7c3e1d0f076acd616cb230834e75a77e8516dde382720af32832d6d
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--entrypoint "valkey-server"
|
||||
--health-cmd="valkey-cli ping"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=5
|
||||
steps:
|
||||
- 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:
|
||||
egress-policy: allow
|
||||
egress-policy: audit
|
||||
allowed-endpoints: |
|
||||
ee.formbricks.com:443
|
||||
registry-1.docker.io:443
|
||||
docker.io:443
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -92,8 +88,69 @@ jobs:
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
echo "S3_REGION=us-east-1" >> .env
|
||||
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
|
||||
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
|
||||
echo "S3_ACCESS_KEY=devminio" >> .env
|
||||
echo "S3_SECRET_KEY=devminio123" >> .env
|
||||
echo "S3_FORCE_PATH_STYLE=1" >> .env
|
||||
shell: bash
|
||||
|
||||
- name: Install MinIO client (mc)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
|
||||
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
|
||||
MC_BIN="mc.${MC_VERSION}"
|
||||
MC_SUM="${MC_BIN}.sha256sum"
|
||||
|
||||
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
|
||||
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
|
||||
|
||||
sha256sum -c "${MC_SUM}"
|
||||
|
||||
chmod +x "${MC_BIN}"
|
||||
sudo mv "${MC_BIN}" /usr/local/bin/mc
|
||||
|
||||
- name: 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
|
||||
run: |
|
||||
pnpm build --filter=@formbricks/web...
|
||||
@@ -109,6 +166,12 @@ jobs:
|
||||
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
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
@@ -118,6 +181,12 @@ jobs:
|
||||
fi
|
||||
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
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
@@ -159,11 +228,14 @@ jobs:
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test:e2e
|
||||
|
||||
|
||||
143
.github/workflows/formbricks-release.yml
vendored
143
.github/workflows/formbricks-release.yml
vendored
@@ -8,16 +8,103 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release docker image
|
||||
check-latest-release:
|
||||
name: Check if this is the latest release
|
||||
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
|
||||
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:
|
||||
name: Release Helm Chart
|
||||
@@ -27,22 +114,44 @@ jobs:
|
||||
uses: ./.github/workflows/release-helm-chart.yml
|
||||
secrets: inherit
|
||||
needs:
|
||||
- docker-build
|
||||
- docker-build-community
|
||||
with:
|
||||
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||
VERSION: ${{ needs.docker-build-community.outputs.VERSION }}
|
||||
|
||||
deploy-formbricks-cloud:
|
||||
name: Deploy Helm Chart to Formbricks Cloud
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
secrets: inherit
|
||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||
verify-cloud-build:
|
||||
name: Verify Cloud Build Outputs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5 # Simple verification should be quick
|
||||
needs:
|
||||
- docker-build
|
||||
- helm-chart-release
|
||||
- docker-build-cloud
|
||||
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:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
|
||||
release_tag: ${{ github.event.release.tag_name }}
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
name: Docker Release to Github Experimental
|
||||
name: Build Community Testing Images
|
||||
|
||||
# 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.
|
||||
# This workflow builds experimental/testing versions of Formbricks for self-hosting customers
|
||||
# to test fixes and features before official releases. Images are pushed to GHCR with
|
||||
# timestamped experimental versions for easy identification and testing.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
inputs:
|
||||
version_override:
|
||||
description: "Override version (SemVer only, e.g., 1.2.3-beta). Leave empty for auto-generated experimental version."
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
build-community-testing:
|
||||
name: Build Community Testing Image
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- 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:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -42,110 +34,17 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
- name: Build and push community testing image
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
registry_type: "ghcr"
|
||||
ghcr_image_name: "${{ github.repository }}-experimental"
|
||||
experimental_mode: "true"
|
||||
version: ${{ inputs.version_override }}
|
||||
env:
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
run: |
|
||||
# Get reference name and type from environment variables
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
# Create unique timestamped version for testing sourcemap resolution
|
||||
TIMESTAMP=$(date +%s)
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name + timestamp
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
VERSION="${BASE_VERSION}-${TIMESTAMP}"
|
||||
echo "Using SemVer tag with timestamp: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_TAG}-${TIMESTAMP}"
|
||||
echo "Using tag as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease + timestamp
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-${SANITIZED_BRANCH}-${TIMESTAMP}"
|
||||
echo "Using branch as prerelease with timestamp: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||
cat ./apps/web/package.json | grep version
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
# 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:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# 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 }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=raw,value=${{ env.VERSION }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- 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 }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# 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:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}
|
||||
|
||||
136
.github/workflows/release-docker-github.yml
vendored
136
.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.
|
||||
# They are provided by a third-party and are governed by
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
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:
|
||||
VERSION:
|
||||
description: release version
|
||||
@@ -23,8 +28,6 @@ env:
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -32,6 +35,7 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -44,103 +48,61 @@ jobs:
|
||||
|
||||
steps:
|
||||
- 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:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get Release Tag
|
||||
- name: Extract release version from tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG="$GITHUB_REF"
|
||||
TAG=${TAG#refs/tags/v}
|
||||
set -euo pipefail
|
||||
|
||||
# Validate the extracted tag format
|
||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
||||
echo "Original ref: $GITHUB_REF"
|
||||
echo "Extracted tag: $TAG"
|
||||
# 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
|
||||
|
||||
# Safely add to environment variables
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
echo "Using version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
||||
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
|
||||
- name: Build and push community release image
|
||||
id: build
|
||||
uses: ./.github/actions/build-and-push-docker
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# 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 }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
- 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 }}
|
||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# 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' }}
|
||||
registry_type: "ghcr"
|
||||
ghcr_image_name: ${{ env.IMAGE_NAME }}
|
||||
version: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||
is_prerelease: ${{ inputs.IS_PRERELEASE }}
|
||||
make_latest: ${{ inputs.MAKE_LATEST }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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 }}
|
||||
|
||||
29
.github/workflows/release-helm-chart.yml
vendored
29
.github/workflows/release-helm-chart.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- 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:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -59,14 +59,35 @@ jobs:
|
||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||
|
||||
- name: Update Chart.yaml with new version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "Packaging Helm chart version: ${VERSION}"
|
||||
helm package ./helm-chart
|
||||
|
||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||
|
||||
- name: Push Helm chart to GitHub Container Registry
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
helm push "formbricks-$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"
|
||||
|
||||
86
.github/workflows/terraform-plan-and-apply.yml
vendored
86
.github/workflows/terraform-plan-and-apply.yml
vendored
@@ -1,86 +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/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
terraform:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
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@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
||||
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"
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -56,21 +56,9 @@ packages/database/migrations
|
||||
branch.json
|
||||
.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
|
||||
/.idea/
|
||||
/*.iml
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
{
|
||||
"language": "ja-JP",
|
||||
"path": "./apps/web/locales/ja-JP.json"
|
||||
},
|
||||
{
|
||||
"language": "zh-Hans-CN",
|
||||
"path": "./apps/web/locales/zh-Hans-CN.json"
|
||||
}
|
||||
],
|
||||
"forceMode": "OVERRIDE"
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
|
||||
"eslint.workingDirectories": [{ "mode": "auto" }],
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.3.5",
|
||||
"vite": "6.3.6",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ RUN pnpm build --filter=@formbricks/database
|
||||
# This mounts the secrets only during this build step without storing them in layers
|
||||
RUN --mount=type=secret,id=database_url \
|
||||
--mount=type=secret,id=encryption_key \
|
||||
--mount=type=secret,id=redis_url \
|
||||
--mount=type=secret,id=sentry_auth_token \
|
||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||
|
||||
@@ -114,9 +115,6 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
||||
COPY --from=installer /prisma_version.txt .
|
||||
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||
|
||||
COPY /docker/cronjobs /app/docker/cronjobs
|
||||
RUN chmod -R 755 /app/docker/cronjobs
|
||||
|
||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ describe("ConnectWithFormbricks", () => {
|
||||
const webAppUrl = "http://app";
|
||||
const channel = {} as any;
|
||||
|
||||
test("renders waiting state when widgetSetupCompleted is false", () => {
|
||||
test("renders waiting state when appSetupCompleted is false", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
appSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -36,12 +36,12 @@ describe("ConnectWithFormbricks", () => {
|
||||
expect(screen.getByText("environments.connect.waiting_for_your_signal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders success state when widgetSetupCompleted is true", () => {
|
||||
test("renders success state when appSetupCompleted is true", () => {
|
||||
render(
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
appSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -54,7 +54,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={true}
|
||||
appSetupCompleted={true}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
@@ -68,7 +68,7 @@ describe("ConnectWithFormbricks", () => {
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={webAppUrl}
|
||||
widgetSetupCompleted={false}
|
||||
appSetupCompleted={false}
|
||||
channel={channel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -13,14 +13,14 @@ import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||
interface ConnectWithFormbricksProps {
|
||||
environment: TEnvironment;
|
||||
publicDomain: string;
|
||||
widgetSetupCompleted: boolean;
|
||||
appSetupCompleted: boolean;
|
||||
channel: TProjectConfigChannel;
|
||||
}
|
||||
|
||||
export const ConnectWithFormbricks = ({
|
||||
environment,
|
||||
publicDomain,
|
||||
widgetSetupCompleted,
|
||||
appSetupCompleted,
|
||||
channel,
|
||||
}: ConnectWithFormbricksProps) => {
|
||||
const { t } = useTranslate();
|
||||
@@ -51,15 +51,15 @@ export const ConnectWithFormbricks = ({
|
||||
environmentId={environment.id}
|
||||
publicDomain={publicDomain}
|
||||
channel={channel}
|
||||
widgetSetupCompleted={widgetSetupCompleted}
|
||||
appSetupCompleted={appSetupCompleted}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[30rem] w-1/2 flex-col items-center justify-center rounded-lg border text-center",
|
||||
widgetSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
appSetupCompleted ? "border-green-500 bg-green-100" : "border-slate-300 bg-slate-200"
|
||||
)}>
|
||||
{widgetSetupCompleted ? (
|
||||
{appSetupCompleted ? (
|
||||
<div>
|
||||
<p className="text-3xl">{t("environments.connect.congrats")}</p>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
@@ -81,9 +81,9 @@ export const ConnectWithFormbricks = ({
|
||||
</div>
|
||||
<Button
|
||||
id="finishOnboarding"
|
||||
variant={widgetSetupCompleted ? "default" : "ghost"}
|
||||
variant={appSetupCompleted ? "default" : "ghost"}
|
||||
onClick={handleFinishOnboarding}>
|
||||
{widgetSetupCompleted
|
||||
{appSetupCompleted
|
||||
? t("environments.connect.finish_onboarding")
|
||||
: t("environments.connect.do_it_later")}
|
||||
<ArrowRight />
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("OnboardingSetupInstructions", () => {
|
||||
environmentId: "env-123",
|
||||
publicDomain: "https://example.com",
|
||||
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||
widgetSetupCompleted: false,
|
||||
appSetupCompleted: false,
|
||||
};
|
||||
|
||||
test("renders HTML tab content by default", () => {
|
||||
|
||||
@@ -20,14 +20,14 @@ interface OnboardingSetupInstructionsProps {
|
||||
environmentId: string;
|
||||
publicDomain: string;
|
||||
channel: TProjectConfigChannel;
|
||||
widgetSetupCompleted: boolean;
|
||||
appSetupCompleted: boolean;
|
||||
}
|
||||
|
||||
export const OnboardingSetupInstructions = ({
|
||||
environmentId,
|
||||
publicDomain,
|
||||
channel,
|
||||
widgetSetupCompleted,
|
||||
appSetupCompleted,
|
||||
}: OnboardingSetupInstructionsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [activeTab, setActiveTab] = useState(tabs[0].id);
|
||||
@@ -137,7 +137,7 @@ export const OnboardingSetupInstructions = ({
|
||||
<div className="mt-4 flex justify-between space-x-2">
|
||||
<Button
|
||||
id="onboarding-inapp-connect-copy-code"
|
||||
variant={widgetSetupCompleted ? "secondary" : "default"}
|
||||
variant={appSetupCompleted ? "secondary" : "default"}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
channel === "app" ? htmlSnippetForAppSurveys : htmlSnippetForWebsiteSurveys
|
||||
|
||||
@@ -42,7 +42,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
<ConnectWithFormbricks
|
||||
environment={environment}
|
||||
publicDomain={publicDomain}
|
||||
widgetSetupCompleted={environment.appSetupCompleted}
|
||||
appSetupCompleted={environment.appSetupCompleted}
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -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 {
|
||||
buildCTAQuestion,
|
||||
buildNPSQuestion,
|
||||
@@ -5,10 +9,6 @@ import {
|
||||
buildRatingQuestion,
|
||||
getDefaultEndingCard,
|
||||
} 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 => {
|
||||
try {
|
||||
@@ -105,7 +105,7 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.star_rating_survey_question_2_html"),
|
||||
subheader: t("templates.star_rating_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -322,7 +322,7 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
||||
}),
|
||||
buildCTAQuestion({
|
||||
id: reusableQuestionIds[1],
|
||||
html: t("templates.smileys_survey_question_2_html"),
|
||||
subheader: t("templates.smileys_survey_question_2_html"),
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
"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 { cn } from "@/lib/cn";
|
||||
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||
@@ -12,13 +18,6 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
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";
|
||||
|
||||
interface LandingSidebarProps {
|
||||
user: TUser;
|
||||
@@ -66,10 +65,8 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
)}>
|
||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||
</p>
|
||||
<p
|
||||
title={capitalizeFirstLetter(organization?.name)}
|
||||
className="truncate text-sm text-slate-500">
|
||||
{capitalizeFirstLetter(organization?.name)}
|
||||
<p title={organization?.name} className="truncate text-sm text-slate-500">
|
||||
{organization?.name}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
||||
|
||||
@@ -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,58 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CreateNewActionTab } from "@/modules/survey/editor/components/create-new-action-tab";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { MousePointerClickIcon, PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
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>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent disableCloseOnOutsideClick>
|
||||
<DialogHeader>
|
||||
<MousePointerClickIcon />
|
||||
<DialogTitle>{t("environments.actions.track_new_user_action")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.actions.track_user_action_to_display_surveys_or_create_user_segment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody>
|
||||
<CreateNewActionTab
|
||||
actionClasses={newActionClasses}
|
||||
environmentId={environmentId}
|
||||
isReadOnly={isReadOnly}
|
||||
setActionClasses={setNewActionClasses}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</DialogBody>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,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" />,
|
||||
};
|
||||
@@ -142,7 +142,6 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
membershipRole={membershipRole}
|
||||
projectPermission={projectPermission}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto">{children}</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 userEvent from "@testing-library/user-event";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -7,7 +6,8 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { getLatestStableFbReleaseAction } from "../actions/actions";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||
import { MainNavigation } from "./MainNavigation";
|
||||
|
||||
// Mock constants that this test needs
|
||||
@@ -32,7 +32,7 @@ vi.mock("next-auth/react", () => ({
|
||||
vi.mock("@/modules/auth/hooks/use-sign-out", () => ({
|
||||
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(),
|
||||
}));
|
||||
vi.mock("@/app/lib/formbricks", () => ({
|
||||
@@ -197,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 () => {
|
||||
const mockSignOut = vi.fn().mockResolvedValue({ url: "/auth/login" });
|
||||
vi.mocked(useSignOut).mockReturnValue({ signOut: mockSignOut });
|
||||
@@ -218,9 +210,10 @@ describe("MainNavigation", () => {
|
||||
expect(userTrigger).toBeInTheDocument(); // Ensure the trigger element is found
|
||||
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(() => {
|
||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||
const accountElements = screen.getAllByText("common.account");
|
||||
expect(accountElements).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||
|
||||
@@ -1,29 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||
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 { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BlocksIcon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MousePointerClick,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
RocketIcon,
|
||||
@@ -38,6 +21,21 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
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";
|
||||
|
||||
interface NavigationProps {
|
||||
@@ -62,7 +60,7 @@ export const MainNavigation = ({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslate();
|
||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||
const [latestVersion, setLatestVersion] = useState("");
|
||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||
@@ -112,18 +110,6 @@ export const MainNavigation = ({
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
},
|
||||
{
|
||||
name: t("common.actions"),
|
||||
href: `/environments/${environment.id}/actions`,
|
||||
icon: MousePointerClick,
|
||||
isActive: pathname?.includes("/actions"),
|
||||
},
|
||||
{
|
||||
name: t("common.integrations"),
|
||||
href: `/environments/${environment.id}/integrations`,
|
||||
icon: BlocksIcon,
|
||||
isActive: pathname?.includes("/integrations"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/project/general`,
|
||||
|
||||
@@ -36,8 +36,6 @@ describe("PosthogIdentify", () => {
|
||||
{
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "engineer",
|
||||
objective: "increase_conversion",
|
||||
} as TUser
|
||||
}
|
||||
environmentId="env-456"
|
||||
@@ -57,8 +55,6 @@ describe("PosthogIdentify", () => {
|
||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: "engineer",
|
||||
objective: "increase_conversion",
|
||||
});
|
||||
|
||||
// environment + organization groups
|
||||
@@ -142,8 +138,6 @@ describe("PosthogIdentify", () => {
|
||||
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
role: undefined,
|
||||
objective: undefined,
|
||||
});
|
||||
// No environmentId or organizationId => no group calls
|
||||
expect(mockGroup).not.toHaveBeenCalled();
|
||||
|
||||
@@ -32,8 +32,6 @@ export const PosthogIdentify = ({
|
||||
posthog.identify(session.user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
objective: user.objective,
|
||||
});
|
||||
if (environmentId) {
|
||||
posthog.group("environment", environmentId, { name: environmentId });
|
||||
@@ -56,8 +54,6 @@ export const PosthogIdentify = ({
|
||||
organizationBilling,
|
||||
user.name,
|
||||
user.email,
|
||||
user.role,
|
||||
user.objective,
|
||||
isPosthogEnabled,
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
|
||||
@@ -26,7 +19,6 @@ interface TopControlBarProps {
|
||||
isOwnerOrManager: boolean;
|
||||
isAccessControlAllowed: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
projectPermission: TTeamPermission | null;
|
||||
}
|
||||
|
||||
export const TopControlBar = ({
|
||||
@@ -42,70 +34,29 @@ export const TopControlBar = ({
|
||||
isOwnerOrManager,
|
||||
isAccessControlAllowed,
|
||||
membershipRole,
|
||||
projectPermission,
|
||||
}: TopControlBarProps) => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||
const isReadOnly = isMember && hasReadAccess;
|
||||
const { isMember } = getAccessFlags(membershipRole);
|
||||
const { environment } = useEnvironment();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
||||
data-testid="fb__global-top-control-bar">
|
||||
<div className="flex items-center">
|
||||
<ProjectAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-50 flex items-center space-x-2">
|
||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link
|
||||
href="https://github.com/formbricks/formbricks/issues"
|
||||
target="_blank"
|
||||
aria-label={t("common.share_feedback")}
|
||||
rel="noopener noreferrer">
|
||||
<BugIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
|
||||
<CircleUserIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
{isBilling || isReadOnly ? (
|
||||
<></>
|
||||
) : (
|
||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
|
||||
<Link
|
||||
href={`/environments/${environment.id}/surveys/templates`}
|
||||
aria-label={t("common.new_survey")}>
|
||||
<PlusIcon />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipRenderer>
|
||||
)}
|
||||
</div>
|
||||
<ProjectAndOrgSwitch
|
||||
currentEnvironmentId={environment.id}
|
||||
environments={environments}
|
||||
currentOrganizationId={currentOrganizationId}
|
||||
organizations={organizations}
|
||||
currentProjectId={currentProjectId}
|
||||
projects={projects}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
organizationProjectsLimit={organizationProjectsLimit}
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isOwnerOrManager={isOwnerOrManager}
|
||||
isMember={isMember}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -143,10 +143,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
|
||||
test("renders environment breadcrumb with production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
||||
@@ -159,7 +156,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -172,7 +169,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -182,10 +179,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
|
||||
test("does not highlight breadcrumb item for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
||||
@@ -195,10 +189,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
test("shows chevron down icon when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
@@ -212,10 +203,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
test("renders dropdown content with environment options", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
@@ -229,10 +217,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
test("renders all environment options in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
@@ -255,10 +240,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
test("handles environment change when clicking dropdown option", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
||||
@@ -275,10 +257,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
|
||||
test("capitalizes environment type in display", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
const environmentSpans = screen.getAllByText("production");
|
||||
@@ -290,7 +269,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
||||
currentEnvironment={mockDevelopmentEnvironment}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -301,10 +280,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
|
||||
test("renders without tooltip for production environment", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
||||
@@ -314,10 +290,7 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={mockEnvironments}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
/>
|
||||
<EnvironmentBreadcrumb environments={mockEnvironments} currentEnvironment={mockProductionEnvironment} />
|
||||
);
|
||||
|
||||
// Initially not active
|
||||
@@ -339,11 +312,18 @@ describe("EnvironmentBreadcrumb", () => {
|
||||
render(
|
||||
<EnvironmentBreadcrumb
|
||||
environments={singleEnvironment}
|
||||
currentEnvironmentId={mockProductionEnvironment.id}
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,22 +16,18 @@ import { useState } from "react";
|
||||
|
||||
export const EnvironmentBreadcrumb = ({
|
||||
environments,
|
||||
currentEnvironmentId,
|
||||
currentEnvironment,
|
||||
}: {
|
||||
environments: { id: string; type: string }[];
|
||||
currentEnvironmentId: string;
|
||||
currentEnvironment: { id: string; type: string };
|
||||
}) => {
|
||||
const { t } = useTranslate();
|
||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
|
||||
if (!currentEnvironment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleEnvironmentChange = (environmentId: string) => {
|
||||
if (environmentId === currentEnvironment.id) return;
|
||||
setIsLoading(true);
|
||||
router.push(`/environments/${environmentId}/`);
|
||||
};
|
||||
@@ -77,7 +73,7 @@ export const EnvironmentBreadcrumb = ({
|
||||
{environments.map((env) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={env.id}
|
||||
checked={env.id === currentEnvironment.id}
|
||||
checked={env.type === currentEnvironment.type}
|
||||
onClick={() => handleEnvironmentChange(env.id)}
|
||||
className="cursor-pointer">
|
||||
<div className="flex items-center gap-2 capitalize">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
BuildingIcon,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface OrganizationBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
@@ -50,10 +52,14 @@ export const OrganizationBreadcrumb = ({
|
||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
||||
|
||||
if (!currentOrganization) {
|
||||
return null;
|
||||
const errorMessage = `Organization not found for organization id: ${currentOrganizationId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === currentOrganizationId) return;
|
||||
setIsLoading(true);
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
};
|
||||
|
||||
@@ -105,7 +105,7 @@ describe("ProjectAndOrgSwitch", () => {
|
||||
id: "env-1",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
updatedAt: new Date("2023-01-01"),
|
||||
type: "production",
|
||||
type: "development",
|
||||
projectId: "proj-1",
|
||||
appSetupCompleted: true,
|
||||
};
|
||||
@@ -160,7 +160,6 @@ describe("ProjectAndOrgSwitch", () => {
|
||||
|
||||
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -234,17 +233,7 @@ describe("ProjectAndOrgSwitch", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: production");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
||||
});
|
||||
|
||||
test("handles development environment", () => {
|
||||
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
|
||||
|
||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment: development");
|
||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
|
||||
});
|
||||
|
||||
test("handles single environment", () => {
|
||||
|
||||
@@ -42,6 +42,8 @@ export const ProjectAndOrgSwitch = ({
|
||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
||||
[organizations]
|
||||
);
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
@@ -66,10 +68,11 @@ export const ProjectAndOrgSwitch = ({
|
||||
isFormbricksCloud={isFormbricksCloud}
|
||||
isLicenseActive={isLicenseActive}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||
/>
|
||||
)}
|
||||
{currentEnvironmentId && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
|
||||
{showEnvironmentBreadcrumb && (
|
||||
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ProjectBreadcrumb } from "./project-breadcrumb";
|
||||
// Mock the dependencies
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: vi.fn(),
|
||||
usePathname: vi.fn(() => "/environments/env-123/project/general"),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
@@ -87,6 +88,7 @@ vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
||||
</button>
|
||||
),
|
||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
||||
}));
|
||||
|
||||
// Mock Lucide React icons
|
||||
@@ -122,6 +124,16 @@ vi.mock("lucide-react", () => ({
|
||||
<title>Loader2 Icon</title>
|
||||
</svg>
|
||||
),
|
||||
CogIcon: ({ className }: any) => (
|
||||
<svg data-testid="cog-icon" className={className}>
|
||||
<title>Cog Icon</title>
|
||||
</svg>
|
||||
),
|
||||
SettingsIcon: ({ className }: any) => (
|
||||
<svg data-testid="settings-icon" className={className}>
|
||||
<title>Settings Icon</title>
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("ProjectBreadcrumb", () => {
|
||||
@@ -177,6 +189,7 @@ describe("ProjectBreadcrumb", () => {
|
||||
isLicenseActive: false,
|
||||
currentEnvironmentId: "env-123",
|
||||
isAccessControlAllowed: true,
|
||||
isEnvironmentBreadcrumbVisible: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -229,7 +242,7 @@ describe("ProjectBreadcrumb", () => {
|
||||
|
||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("dropdown-group")).toHaveLength(2); // Projects group and settings group
|
||||
});
|
||||
|
||||
test("renders all project options in dropdown", async () => {
|
||||
@@ -336,6 +349,7 @@ describe("ProjectBreadcrumb", () => {
|
||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: true,
|
||||
isEnvironmentBreadcrumbVisible: true,
|
||||
currentOrganization: {
|
||||
...mockOrganization,
|
||||
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
|
||||
@@ -361,6 +375,7 @@ describe("ProjectBreadcrumb", () => {
|
||||
organizationProjectsLimit: 3,
|
||||
isFormbricksCloud: false,
|
||||
isLicenseActive: true,
|
||||
isEnvironmentBreadcrumbVisible: true,
|
||||
};
|
||||
render(<ProjectBreadcrumb {...props} />);
|
||||
|
||||
|
||||
@@ -8,13 +8,16 @@ import {
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
interface ProjectBreadcrumbProps {
|
||||
currentProjectId: string;
|
||||
@@ -26,6 +29,7 @@ interface ProjectBreadcrumbProps {
|
||||
currentOrganizationId: string;
|
||||
currentEnvironmentId: string;
|
||||
isAccessControlAllowed: boolean;
|
||||
isEnvironmentBreadcrumbVisible: boolean;
|
||||
}
|
||||
|
||||
export const ProjectBreadcrumb = ({
|
||||
@@ -38,6 +42,7 @@ export const ProjectBreadcrumb = ({
|
||||
currentOrganizationId,
|
||||
currentEnvironmentId,
|
||||
isAccessControlAllowed,
|
||||
isEnvironmentBreadcrumbVisible,
|
||||
}: ProjectBreadcrumbProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||
@@ -45,13 +50,57 @@ export const ProjectBreadcrumb = ({
|
||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
const projectSettings = [
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
||||
logger.error(errorMessage);
|
||||
Sentry.captureException(new Error(errorMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
const handleProjectChange = (projectId: string) => {
|
||||
if (projectId === currentProjectId) return;
|
||||
setIsLoading(true);
|
||||
router.push(`/projects/${projectId}/`);
|
||||
};
|
||||
@@ -105,14 +154,14 @@ export const ProjectBreadcrumb = ({
|
||||
{isProjectDropdownOpen ? (
|
||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
|
||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
<DropdownMenuGroup>
|
||||
@@ -133,9 +182,25 @@ export const ProjectBreadcrumb = ({
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||
{t("common.project_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={setting.id}
|
||||
checked={pathname.includes(setting.id)}
|
||||
onClick={() => router.push(setting.href)}
|
||||
className="cursor-pointer">
|
||||
{setting.label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* Modals */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -16,11 +16,11 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown",
|
||||
() => ({
|
||||
BaseSelectDropdown: ({ control, airtableArray, fetchTable, defaultValue, setValue }) => (
|
||||
<div>
|
||||
@@ -44,7 +44,7 @@ vi.mock(
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable", () => ({
|
||||
fetchTables: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
@@ -1,8 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
@@ -27,20 +42,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { TFnType, useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Control, Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationAirtable,
|
||||
TIntegrationAirtableConfigData,
|
||||
TIntegrationAirtableInput,
|
||||
TIntegrationAirtableTables,
|
||||
} from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
type EditModeProps =
|
||||
| { isEditMode: false; defaultData?: never }
|
||||
@@ -55,17 +57,6 @@ type AddIntegrationModalProps = {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
} & EditModeProps;
|
||||
|
||||
export type IntegrationModalInputs = {
|
||||
base: string;
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
includeCreatedAt: boolean;
|
||||
};
|
||||
|
||||
const NoBaseFoundError = () => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
@@ -127,7 +118,9 @@ const renderQuestionSelection = ({
|
||||
: field.onChange(field.value?.filter((value) => value !== question.id));
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{getLocalizedValue(question.headline, "default")}</span>
|
||||
<span className="ml-2">
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
@@ -239,7 +232,7 @@ export const AddIntegrationModal = ({
|
||||
|
||||
if (isEditMode) {
|
||||
// update action
|
||||
airtableIntegrationData.config!.data[defaultData.index] = integrationData;
|
||||
airtableIntegrationData.config.data[defaultData.index] = integrationData;
|
||||
} else {
|
||||
// create action
|
||||
airtableIntegrationData.config?.data.push(integrationData);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -8,7 +8,7 @@ import { AirtableWrapper } from "./AirtableWrapper";
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: ({ setIsConnected }) => (
|
||||
<div data-testid="manage-integration">
|
||||
@@ -28,7 +28,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import airtableLogo from "@/images/airtableLogo.svg";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { useState } from "react";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { IntegrationModalInputs } from "./AddIntegrationModal";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface BaseSelectProps {
|
||||
control: Control<IntegrationModalInputs, any>;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -6,11 +6,11 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: ({ open, setOpenWithStates }) =>
|
||||
open ? (
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import {
|
||||
AddIntegrationModal,
|
||||
IntegrationModalInputs,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -19,6 +16,7 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { IntegrationModalInputs } from "../lib/types";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
airtableIntegration: TIntegrationAirtable;
|
||||
@@ -0,0 +1,10 @@
|
||||
export type IntegrationModalInputs = {
|
||||
base: string;
|
||||
table: string;
|
||||
survey: string;
|
||||
questions: string[];
|
||||
includeVariables: boolean;
|
||||
includeHiddenFields: boolean;
|
||||
includeMetadata: boolean;
|
||||
includeCreatedAt: boolean;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -15,10 +15,13 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import Page from "./page";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper", () => ({
|
||||
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys");
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper",
|
||||
() => ({
|
||||
AirtableWrapper: vi.fn(() => <div>AirtableWrapper Mock</div>),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys");
|
||||
vi.mock("@/lib/airtable/service");
|
||||
|
||||
let mockAirtableClientId: string | undefined = "test-client-id";
|
||||
@@ -139,7 +142,7 @@ describe("Airtable Integration Page", () => {
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
@@ -175,7 +178,7 @@ describe("Airtable Integration Page", () => {
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
@@ -206,7 +209,7 @@ describe("Airtable Integration Page", () => {
|
||||
const AirtableWrapper = vi.mocked(
|
||||
(
|
||||
await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper"
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper"
|
||||
)
|
||||
).AirtableWrapper
|
||||
);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<AirtableWrapper
|
||||
@@ -1,8 +1,7 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/AddIntegrationModal";
|
||||
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 {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
@@ -10,13 +9,13 @@ import {
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions", () => ({
|
||||
getSpreadsheetNameByIdAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util", () => ({
|
||||
constructGoogleSheetsUrl: (id: string) => `https://docs.google.com/spreadsheets/d/${id}`,
|
||||
extractSpreadsheetIdFromUrl: (url: string) => url.split("/")[5],
|
||||
isValidGoogleSheetsUrl: (url: string) => url.startsWith("https://docs.google.com/spreadsheets/d/"),
|
||||
@@ -164,11 +163,11 @@ vi.mock("@tolgee/react", async () => {
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
(await import("@/app/(app)/environments/[environmentId]/project/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const getSpreadsheetNameByIdAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions"))
|
||||
(await import("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions"))
|
||||
.getSpreadsheetNameByIdAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
@@ -206,7 +205,6 @@ const surveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -243,7 +241,6 @@ const surveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -1,12 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/actions";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||
import {
|
||||
constructGoogleSheetsUrl,
|
||||
extractSpreadsheetIdFromUrl,
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -26,17 +38,6 @@ import {
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
TIntegrationGoogleSheetsInput,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
@@ -154,10 +155,10 @@ export const AddIntegrationModal = ({
|
||||
integrationData.includeCreatedAt = includeCreatedAt;
|
||||
if (selectedIntegration) {
|
||||
// update action
|
||||
googleSheetIntegrationData.config!.data[selectedIntegration.index] = integrationData;
|
||||
googleSheetIntegrationData.config.data[selectedIntegration.index] = integrationData;
|
||||
} else {
|
||||
// create action
|
||||
googleSheetIntegrationData.config!.data.push(integrationData);
|
||||
googleSheetIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
if (selectedIntegration) {
|
||||
@@ -196,7 +197,7 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const deleteLink = async () => {
|
||||
googleSheetIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
|
||||
googleSheetIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: googleSheetIntegrationData });
|
||||
@@ -276,7 +277,7 @@ export const AddIntegrationModal = ({
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2 w-[30rem] truncate">
|
||||
{getLocalizedValue(question.headline, "default")}
|
||||
{getTextContent(getLocalizedValue(question.headline, "default"))}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
@@ -12,7 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock child components and functions
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration",
|
||||
() => ({
|
||||
ManageIntegration: vi.fn(({ setOpenAddIntegrationModal }) => (
|
||||
<div data-testid="manage-integration">
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/AddIntegrationModal",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: vi.fn(({ open }) =>
|
||||
open ? <div data-testid="add-integration-modal">Modal</div> : null
|
||||
@@ -39,7 +39,7 @@ vi.mock(
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google", () => ({
|
||||
authorize: vi.fn(() => Promise.resolve("http://google.com/auth")),
|
||||
}));
|
||||
|
||||
@@ -129,7 +129,7 @@ describe("GoogleSheetWrapper", () => {
|
||||
|
||||
expect(vi.mocked(authorize)).toHaveBeenCalledWith(mockEnvironment.id, mockWebAppUrl);
|
||||
// Need to wait for the promise returned by authorize to resolve
|
||||
await vi.waitFor(() => {
|
||||
await waitFor(() => {
|
||||
expect(window.location.replace).toHaveBeenCalledWith("http://google.com/auth");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/google";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { useState } from "react";
|
||||
@@ -34,7 +34,7 @@ export const GoogleSheetWrapper = ({
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||
);
|
||||
const [isModalOpen, setModalOpen] = useState<boolean>(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
||||
>(null);
|
||||
@@ -55,14 +55,14 @@ export const GoogleSheetWrapper = ({
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
selectedIntegration={selectedIntegration}
|
||||
/>
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
googleSheetIntegration={googleSheetIntegration}
|
||||
setOpenAddIntegrationModal={setModalOpen}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={locale}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -6,7 +6,7 @@ import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -108,24 +108,21 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
</div>
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||
<div className="col-span-2 text-center">{data.questions}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.spreadsheetName}</div>
|
||||
<div className="col-span-2 text-center">{data.questions}</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const extractSpreadsheetIdFromUrl = (url: string): string => {
|
||||
const regex = /\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/;
|
||||
const match = url.match(regex);
|
||||
if (match && match[1]) {
|
||||
const match = regex.exec(url);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
} else {
|
||||
throw new Error("Invalid Google Sheets URL");
|
||||
@@ -27,7 +27,7 @@ const Loading = () => {
|
||||
<div className="grid-cols-7">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={`${index.toString()}-google-sheet-loading`}
|
||||
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="text-left">
|
||||
@@ -1,5 +1,5 @@
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/page";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/page";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||
@@ -16,7 +16,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper",
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper",
|
||||
() => ({
|
||||
GoogleSheetWrapper: vi.fn(
|
||||
({ isEnabled, environment, surveys, googleSheetIntegration, webAppUrl, locale }) => (
|
||||
@@ -33,7 +33,7 @@ vi.mock(
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -113,7 +113,6 @@ const mockSurveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -125,7 +124,6 @@ const mockSurveys: TSurvey[] = [
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -178,7 +176,7 @@ describe("GoogleSheetsIntegrationPage", () => {
|
||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||
`test-webapp-url/environments/${mockProps.params.environmentId}/project/integrations`
|
||||
);
|
||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -199,7 +197,7 @@ describe("GoogleSheetsIntegrationPage", () => {
|
||||
mockGoogleSheetClientId = undefined;
|
||||
|
||||
const { default: PageWithMissingConstants } = (await import(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/google-sheets/page"
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/page"
|
||||
)) as { default: typeof Page };
|
||||
vi.mocked(getEnvironmentAuth).mockResolvedValue({
|
||||
environment: mockEnvironment,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
||||
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";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock actions and utilities
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
createOrUpdateIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
@@ -184,7 +184,7 @@ vi.mock("@tolgee/react", async () => {
|
||||
|
||||
// Mock dependencies
|
||||
const createOrUpdateIntegrationAction = vi.mocked(
|
||||
(await import("@/app/(app)/environments/[environmentId]/integrations/actions"))
|
||||
(await import("@/app/(app)/environments/[environmentId]/project/integrations/actions"))
|
||||
.createOrUpdateIntegrationAction
|
||||
);
|
||||
const toast = vi.mocked((await import("react-hot-toast")).default);
|
||||
@@ -224,7 +224,6 @@ const surveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -259,7 +258,6 @@ const surveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
@@ -23,19 +36,6 @@ import {
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { PlusIcon, TrashIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||
import {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionDatabase,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddIntegrationModalProps {
|
||||
environmentId: string;
|
||||
@@ -134,13 +134,12 @@ export const AddIntegrationModal = ({
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
|
||||
const hiddenFields = selectedSurvey?.hiddenFields.enabled
|
||||
? selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: `${t("common.hidden_field")} : ${fId}`,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || []
|
||||
: [];
|
||||
const hiddenFields =
|
||||
selectedSurvey?.hiddenFields.fieldIds?.map((fId) => ({
|
||||
id: fId,
|
||||
name: `${t("common.hidden_field")} : ${fId}`,
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
})) || [];
|
||||
const Metadata = [
|
||||
{
|
||||
id: "metadata",
|
||||
@@ -221,10 +220,10 @@ export const AddIntegrationModal = ({
|
||||
|
||||
if (selectedIntegration) {
|
||||
// update action
|
||||
notionIntegrationData.config!.data[selectedIntegration.index] = integrationData;
|
||||
notionIntegrationData.config.data[selectedIntegration.index] = integrationData;
|
||||
} else {
|
||||
// create action
|
||||
notionIntegrationData.config!.data.push(integrationData);
|
||||
notionIntegrationData.config.data.push(integrationData);
|
||||
}
|
||||
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
@@ -243,7 +242,7 @@ export const AddIntegrationModal = ({
|
||||
};
|
||||
|
||||
const deleteLink = async () => {
|
||||
notionIntegrationData.config!.data.splice(selectedIntegration!.index, 1);
|
||||
notionIntegrationData.config.data.splice(selectedIntegration!.index, 1);
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await createOrUpdateIntegrationAction({ environmentId, integrationData: notionIntegrationData });
|
||||
@@ -9,9 +9,11 @@ import type {
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -118,23 +118,20 @@ export const ManageIntegration = ({
|
||||
</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
|
||||
</div>
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.databaseName}</div>
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{integrationArray.map((data, index) => {
|
||||
return (
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||
<div className="col-span-2 text-center">{data.databaseName}</div>
|
||||
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/lib/notion";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -61,7 +61,7 @@ vi.mock("@/modules/ui/components/connect-integration", () => ({
|
||||
}));
|
||||
|
||||
// Mock library function
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/lib/notion", () => ({
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/notion/lib/notion", () => ({
|
||||
authorize: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("NotionWrapper", () => {
|
||||
test("calls authorize and redirects when Connect button is clicked", async () => {
|
||||
const mockAuthorize = vi.mocked(authorize);
|
||||
const redirectUrl = "https://notion.com/auth";
|
||||
mockAuthorize.mockResolvedValue(redirectUrl);
|
||||
mockAuthorize.mockImplementation(() => Promise.resolve(redirectUrl));
|
||||
|
||||
render(<NotionWrapper {...baseProps} enabled={true} notionIntegration={undefined} />); // Changed isEnabled to enabled
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/ManageIntegration";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { useState } from "react";
|
||||
@@ -34,7 +34,7 @@ export const NotionWrapper = ({
|
||||
databasesArray,
|
||||
locale,
|
||||
}: NotionWrapperProps) => {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(
|
||||
notionIntegration ? notionIntegration.config.key?.bot_id : false
|
||||
);
|
||||
@@ -58,7 +58,7 @@ export const NotionWrapper = ({
|
||||
environmentId={environment.id}
|
||||
surveys={surveys}
|
||||
open={isModalOpen}
|
||||
setOpen={setModalOpen}
|
||||
setOpen={setIsModalOpen}
|
||||
notionIntegration={notionIntegration}
|
||||
databases={databasesArray}
|
||||
selectedIntegration={selectedIntegration}
|
||||
@@ -66,7 +66,7 @@ export const NotionWrapper = ({
|
||||
<ManageIntegration
|
||||
environment={environment}
|
||||
notionIntegration={notionIntegration}
|
||||
setOpenAddIntegrationModal={setModalOpen}
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
locale={locale}
|
||||
@@ -24,7 +24,7 @@ const Loading = () => {
|
||||
<div className="grid-cols-7">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
key={`${index.toString()}-notion-loading`}
|
||||
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-3 flex items-center pl-6 text-sm">
|
||||
<div className="text-left">
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/integrations/notion/page";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import Page from "@/app/(app)/environments/[environmentId]/project/integrations/notion/page";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { getNotionDatabases } from "@/lib/notion/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
@@ -13,23 +13,26 @@ import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/type
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper", () => ({
|
||||
NotionWrapper: vi.fn(
|
||||
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
|
||||
<div>
|
||||
<span>Mocked NotionWrapper</span>
|
||||
<span data-testid="enabled">{enabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{notionIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
}));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/lib/surveys", () => ({
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper",
|
||||
() => ({
|
||||
NotionWrapper: vi.fn(
|
||||
({ enabled, environment, surveys, notionIntegration, webAppUrl, databasesArray, locale }) => (
|
||||
<div>
|
||||
<span>Mocked NotionWrapper</span>
|
||||
<span data-testid="enabled">{enabled.toString()}</span>
|
||||
<span data-testid="environmentId">{environment.id}</span>
|
||||
<span data-testid="surveyCount">{surveys?.length ?? 0}</span>
|
||||
<span data-testid="integrationId">{notionIntegration?.id}</span>
|
||||
<span data-testid="webAppUrl">{webAppUrl}</span>
|
||||
<span data-testid="databaseCount">{databasesArray?.length ?? 0}</span>
|
||||
<span data-testid="locale">{locale}</span>
|
||||
</div>
|
||||
)
|
||||
),
|
||||
})
|
||||
);
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -122,7 +125,6 @@ const mockSurveys: TSurvey[] = [
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
closeOnDate: null,
|
||||
delay: 0,
|
||||
displayOption: "displayOnce",
|
||||
displayPercentage: null,
|
||||
@@ -134,7 +136,6 @@ const mockSurveys: TSurvey[] = [
|
||||
surveyClosedMessage: null,
|
||||
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
|
||||
autoComplete: null,
|
||||
runOnDate: null,
|
||||
} as unknown as TSurvey,
|
||||
];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user