mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 11:29:22 -05:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb17c22fc2 | |||
| 3d52f7b63b | |||
| 17222a59ef | |||
| 1ab856d2f0 |
+18
-22
@@ -1,8 +1,12 @@
|
|||||||
---
|
---
|
||||||
description: It should be used **only when the agent explicitly requests database schema-level, details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models, investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
description: >
|
||||||
alwaysApply: false
|
This rule provides comprehensive knowledge about the Formbricks database structure, relationships,
|
||||||
|
and data patterns. It should be used **only when the agent explicitly requests database schema-level
|
||||||
|
details** to support tasks such as: writing/debugging Prisma queries, designing/reviewing data models,
|
||||||
|
investigating multi-tenancy behavior, creating API endpoints, or understanding data relationships.
|
||||||
|
globs: []
|
||||||
|
alwaysApply: agent-requested
|
||||||
---
|
---
|
||||||
|
|
||||||
# Formbricks Database Schema Reference
|
# Formbricks Database Schema Reference
|
||||||
|
|
||||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||||
@@ -12,7 +16,6 @@ This rule provides a reference to the Formbricks database structure. For the mos
|
|||||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||||
|
|
||||||
### Core Hierarchy
|
### Core Hierarchy
|
||||||
|
|
||||||
```
|
```
|
||||||
Organization
|
Organization
|
||||||
└── Project
|
└── Project
|
||||||
@@ -26,7 +29,6 @@ Organization
|
|||||||
## Schema Reference
|
## Schema Reference
|
||||||
|
|
||||||
For the complete and up-to-date database schema, please refer to:
|
For the complete and up-to-date database schema, please refer to:
|
||||||
|
|
||||||
- Main schema: `packages/database/schema.prisma`
|
- Main schema: `packages/database/schema.prisma`
|
||||||
- JSON type definitions: `packages/database/json-types.ts`
|
- JSON type definitions: `packages/database/json-types.ts`
|
||||||
|
|
||||||
@@ -35,22 +37,17 @@ The schema.prisma file contains all model definitions, relationships, enums, and
|
|||||||
## Data Access Patterns
|
## Data Access Patterns
|
||||||
|
|
||||||
### Multi-tenancy
|
### Multi-tenancy
|
||||||
|
|
||||||
- All data is scoped by Organization
|
- All data is scoped by Organization
|
||||||
- Environment-level isolation for surveys and contacts
|
- Environment-level isolation for surveys and contacts
|
||||||
- Project-level grouping for related surveys
|
- Project-level grouping for related surveys
|
||||||
|
|
||||||
### Soft Deletion
|
### Soft Deletion
|
||||||
|
|
||||||
Some models use soft deletion patterns:
|
Some models use soft deletion patterns:
|
||||||
|
|
||||||
- Check `isActive` fields where present
|
- Check `isActive` fields where present
|
||||||
- Use proper filtering in queries
|
- Use proper filtering in queries
|
||||||
|
|
||||||
### Cascading Deletes
|
### Cascading Deletes
|
||||||
|
|
||||||
Configured cascade relationships:
|
Configured cascade relationships:
|
||||||
|
|
||||||
- Organization deletion cascades to all child entities
|
- Organization deletion cascades to all child entities
|
||||||
- Survey deletion removes responses, displays, triggers
|
- Survey deletion removes responses, displays, triggers
|
||||||
- Contact deletion removes attributes and responses
|
- Contact deletion removes attributes and responses
|
||||||
@@ -58,7 +55,6 @@ Configured cascade relationships:
|
|||||||
## Common Query Patterns
|
## Common Query Patterns
|
||||||
|
|
||||||
### Survey with Responses
|
### Survey with Responses
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Include response count and latest responses
|
// Include response count and latest responses
|
||||||
const survey = await prisma.survey.findUnique({
|
const survey = await prisma.survey.findUnique({
|
||||||
@@ -66,40 +62,40 @@ const survey = await prisma.survey.findUnique({
|
|||||||
include: {
|
include: {
|
||||||
responses: {
|
responses: {
|
||||||
take: 10,
|
take: 10,
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: 'desc' }
|
||||||
},
|
},
|
||||||
_count: {
|
_count: {
|
||||||
select: { responses: true },
|
select: { responses: true }
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Scoping
|
### Environment Scoping
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Always scope by environment
|
// Always scope by environment
|
||||||
const surveys = await prisma.survey.findMany({
|
const surveys = await prisma.survey.findMany({
|
||||||
where: {
|
where: {
|
||||||
environmentId: environmentId,
|
environmentId: environmentId,
|
||||||
// Additional filters...
|
// Additional filters...
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Contact with Attributes
|
### Contact with Attributes
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: { id: contactId },
|
where: { id: contactId },
|
||||||
include: {
|
include: {
|
||||||
attributes: {
|
attributes: {
|
||||||
include: {
|
include: {
|
||||||
attributeKey: true,
|
attributeKey: true
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
---
|
---
|
||||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||||
globs:
|
globs:
|
||||||
alwaysApply: false
|
alwaysApply: false
|
||||||
---
|
---
|
||||||
|
|
||||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||||
|
|
||||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
title: "FEATURE NAME"
|
title: "FEATURE NAME"
|
||||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||||
icon: "link"
|
icon: "link"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt
|
||||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
|
||||||
- The page should never start with H1 headline, because it's already part of the template.
|
|
||||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
|
||||||
- If a feature is part of the Enterprise Edition, use this note:
|
- If a feature is part of the Enterprise Edition, use this note:
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
FEATURE NAME is part of the @Enterprise Edition.
|
||||||
</Note>
|
</Note>
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
---
|
|
||||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
|
||||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
|
||||||
---
|
|
||||||
|
|
||||||
# GitHub Actions Security Best Practices
|
|
||||||
|
|
||||||
## Required Security Measures
|
|
||||||
|
|
||||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
|
||||||
|
|
||||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
# Only add additional permissions if absolutely necessary:
|
|
||||||
# pull-requests: write # for commenting on PRs
|
|
||||||
# issues: write # for creating/updating issues
|
|
||||||
# checks: write # for publishing check results
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Add Harden-Runner as First Step
|
|
||||||
|
|
||||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit # or 'block' for stricter security
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Pin Actions to Full Commit SHA
|
|
||||||
|
|
||||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - uses mutable tag
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to immutable commit SHA
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Secure Variable Handling
|
|
||||||
|
|
||||||
Prevent command injection by properly quoting variables:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - potential command injection
|
|
||||||
run: echo "Processing ${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
# ✅ GOOD - properly quoted
|
|
||||||
env:
|
|
||||||
USER_INPUT: ${{ inputs.user_input }}
|
|
||||||
run: echo "Processing ${USER_INPUT}"
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
|
||||||
|
|
||||||
### 5. Environment Variables for Secrets
|
|
||||||
|
|
||||||
Store sensitive data in environment variables, not inline:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD
|
|
||||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
|
||||||
|
|
||||||
# ✅ GOOD
|
|
||||||
env:
|
|
||||||
API_TOKEN: ${{ secrets.TOKEN }}
|
|
||||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow Structure Best Practices
|
|
||||||
|
|
||||||
### Required Workflow Elements
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: "Descriptive Workflow Name"
|
|
||||||
|
|
||||||
on:
|
|
||||||
# Define specific triggers
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
# Always set explicit permissions
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
job-name:
|
|
||||||
name: "Descriptive Job Name"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
|
||||||
|
|
||||||
# Set job-level permissions if different from workflow level
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
# Always start with Harden-Runner on ubuntu-latest
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
# Pin all actions to commit SHA
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
```
|
|
||||||
|
|
||||||
### Input Validation for Actions
|
|
||||||
|
|
||||||
For composite actions, always validate inputs:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
inputs:
|
|
||||||
user_input:
|
|
||||||
description: "User provided input"
|
|
||||||
required: true
|
|
||||||
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: Validate input
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Harden shell and validate input format/content before use
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
USER_INPUT="${{ inputs.user_input }}"
|
|
||||||
|
|
||||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Invalid input format"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
## Docker Security in Actions
|
|
||||||
|
|
||||||
### Pin Docker Images to Digests
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ❌ BAD - mutable tag
|
|
||||||
container: node:18
|
|
||||||
|
|
||||||
# ✅ GOOD - pinned to digest
|
|
||||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Secure File Operations
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Process files securely
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
FILE_PATH: ${{ inputs.file_path }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
|
||||||
|
|
||||||
# Use absolute paths and validate
|
|
||||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
|
||||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
|
||||||
echo "❌ Path outside workspace"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Artifact Handling
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
- name: Upload artifacts securely
|
|
||||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
|
||||||
with:
|
|
||||||
name: build-artifacts
|
|
||||||
path: |
|
|
||||||
dist/
|
|
||||||
!dist/**/*.log # Exclude sensitive files
|
|
||||||
retention-days: 30
|
|
||||||
```
|
|
||||||
|
|
||||||
### GHCR authentication for pulls/scans
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Minimal permissions required for GHCR pulls/scans
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Security Checklist
|
|
||||||
|
|
||||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
|
||||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
|
||||||
- [ ] All third-party actions pinned to commit SHA
|
|
||||||
- [ ] Input validation implemented for custom actions
|
|
||||||
- [ ] Variables properly quoted in shell scripts
|
|
||||||
- [ ] Secrets stored in environment variables
|
|
||||||
- [ ] Docker images pinned to digests (if used)
|
|
||||||
- [ ] Error handling with `set -euo pipefail`
|
|
||||||
- [ ] File paths validated and sanitized
|
|
||||||
- [ ] No sensitive data in logs or outputs
|
|
||||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
|
||||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
|
||||||
|
|
||||||
## Recommended Additional Workflows
|
|
||||||
|
|
||||||
Consider adding these security-focused workflows to your repository:
|
|
||||||
|
|
||||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
|
||||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
|
||||||
3. **Dependabot Configuration** - Automated dependency updates
|
|
||||||
|
|
||||||
## Resources
|
|
||||||
|
|
||||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
|
||||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
|
||||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
|
||||||
@@ -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)).
|
|
||||||
@@ -90,7 +90,7 @@ When testing hooks that use React Context:
|
|||||||
vi.mocked(useResponseFilter).mockReturnValue({
|
vi.mocked(useResponseFilter).mockReturnValue({
|
||||||
selectedFilter: {
|
selectedFilter: {
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
},
|
},
|
||||||
setSelectedFilter: vi.fn(),
|
setSelectedFilter: vi.fn(),
|
||||||
selectedOptions: {
|
selectedOptions: {
|
||||||
|
|||||||
@@ -62,12 +62,10 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
- name: Fill ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY and E2E_TESTING in .env
|
||||||
env:
|
|
||||||
E2E_TESTING_MODE: ${{ inputs.e2e_testing_mode }}
|
|
||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
echo "E2E_TESTING=$E2E_TESTING_MODE" >> .env
|
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
name: 'Upload Sentry Sourcemaps'
|
||||||
|
description: 'Extract sourcemaps from Docker image and upload to Sentry'
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
docker_image:
|
||||||
|
description: 'Docker image to extract sourcemaps from'
|
||||||
|
required: true
|
||||||
|
release_version:
|
||||||
|
description: 'Sentry release version (e.g., v1.2.3)'
|
||||||
|
required: true
|
||||||
|
sentry_auth_token:
|
||||||
|
description: 'Sentry authentication token'
|
||||||
|
required: true
|
||||||
|
environment:
|
||||||
|
description: 'Sentry environment (e.g., production, staging)'
|
||||||
|
required: false
|
||||||
|
default: 'staging'
|
||||||
|
|
||||||
|
runs:
|
||||||
|
using: 'composite'
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate Sentry auth token
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "🔐 Validating Sentry authentication token..."
|
||||||
|
|
||||||
|
# Assign token to local variable for secure handling
|
||||||
|
SENTRY_TOKEN="${{ inputs.sentry_auth_token }}"
|
||||||
|
|
||||||
|
# Test the token by making a simple API call to Sentry
|
||||||
|
response=$(curl -s -w "%{http_code}" -o /tmp/sentry_response.json \
|
||||||
|
-H "Authorization: Bearer $SENTRY_TOKEN" \
|
||||||
|
"https://sentry.io/api/0/organizations/formbricks/")
|
||||||
|
|
||||||
|
http_code=$(echo "$response" | tail -n1)
|
||||||
|
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
echo "❌ Error: Invalid Sentry auth token (HTTP $http_code)"
|
||||||
|
echo "Please check your SENTRY_AUTH_TOKEN is correct and has the necessary permissions."
|
||||||
|
if [ -f /tmp/sentry_response.json ]; then
|
||||||
|
echo "Response body:"
|
||||||
|
cat /tmp/sentry_response.json
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Sentry auth token validated successfully"
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
rm -f /tmp/sentry_response.json
|
||||||
|
|
||||||
|
- name: Extract sourcemaps from Docker image
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "📦 Extracting sourcemaps from Docker image: ${{ inputs.docker_image }}"
|
||||||
|
|
||||||
|
# Create temporary container from the image and capture its ID
|
||||||
|
echo "Creating temporary container..."
|
||||||
|
CONTAINER_ID=$(docker create "${{ inputs.docker_image }}")
|
||||||
|
echo "Container created with ID: $CONTAINER_ID"
|
||||||
|
|
||||||
|
# Set up cleanup function to ensure container is removed on script exit
|
||||||
|
cleanup_container() {
|
||||||
|
# Capture the current exit code to preserve it
|
||||||
|
local original_exit_code=$?
|
||||||
|
|
||||||
|
echo "🧹 Cleaning up Docker container..."
|
||||||
|
|
||||||
|
# Remove the container if it exists (ignore errors if already removed)
|
||||||
|
if [ -n "$CONTAINER_ID" ]; then
|
||||||
|
docker rm -f "$CONTAINER_ID" 2>/dev/null || true
|
||||||
|
echo "Container $CONTAINER_ID removed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Exit with the original exit code to preserve script success/failure status
|
||||||
|
exit $original_exit_code
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register cleanup function to run on script exit (success or failure)
|
||||||
|
trap cleanup_container EXIT
|
||||||
|
|
||||||
|
# Extract .next directory containing sourcemaps
|
||||||
|
docker cp "$CONTAINER_ID:/home/nextjs/apps/web/.next" ./extracted-next
|
||||||
|
|
||||||
|
# Verify sourcemaps exist
|
||||||
|
if [ ! -d "./extracted-next/static/chunks" ]; then
|
||||||
|
echo "❌ Error: .next/static/chunks directory not found in Docker image"
|
||||||
|
echo "Expected structure: /home/nextjs/apps/web/.next/static/chunks/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sourcemap_count=$(find ./extracted-next/static/chunks -name "*.map" | wc -l)
|
||||||
|
echo "✅ Found $sourcemap_count sourcemap files"
|
||||||
|
|
||||||
|
if [ "$sourcemap_count" -eq 0 ]; then
|
||||||
|
echo "❌ Error: No sourcemap files found. Check that productionBrowserSourceMaps is enabled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Create Sentry release and upload sourcemaps
|
||||||
|
uses: getsentry/action-release@v3
|
||||||
|
env:
|
||||||
|
SENTRY_AUTH_TOKEN: ${{ inputs.sentry_auth_token }}
|
||||||
|
SENTRY_ORG: formbricks
|
||||||
|
SENTRY_PROJECT: formbricks-cloud
|
||||||
|
with:
|
||||||
|
environment: ${{ inputs.environment }}
|
||||||
|
version: ${{ inputs.release_version }}
|
||||||
|
sourcemaps: './extracted-next/'
|
||||||
|
|
||||||
|
- name: Clean up extracted files
|
||||||
|
shell: bash
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Clean up extracted files
|
||||||
|
rm -rf ./extracted-next
|
||||||
|
echo "🧹 Cleaned up extracted files"
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
name: "Apply issue labels to PR"
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label_on_pr:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: none
|
||||||
|
issues: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Apply labels from linked issue to PR
|
||||||
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
script: |
|
||||||
|
async function getLinkedIssues(owner, repo, prNumber) {
|
||||||
|
const query = `query GetLinkedIssues($owner: String!, $repo: String!, $prNumber: Int!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
pullRequest(number: $prNumber) {
|
||||||
|
closingIssuesReferences(first: 10) {
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
labels(first: 10) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
owner: owner,
|
||||||
|
repo: repo,
|
||||||
|
prNumber: prNumber,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
return result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pr = context.payload.pull_request;
|
||||||
|
const linkedIssues = await getLinkedIssues(
|
||||||
|
context.repo.owner,
|
||||||
|
context.repo.repo,
|
||||||
|
pr.number
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelsToAdd = new Set();
|
||||||
|
for (const issue of linkedIssues) {
|
||||||
|
if (issue.labels && issue.labels.nodes) {
|
||||||
|
for (const label of issue.labels.nodes) {
|
||||||
|
labelsToAdd.add(label.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (labelsToAdd.size) {
|
||||||
|
await github.rest.issues.addLabels({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: pr.number,
|
||||||
|
labels: Array.from(labelsToAdd),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
name: Build & Push Docker to ECR
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
image_tag:
|
|
||||||
description: "Image tag to push (e.g., v3.16.1, main)"
|
|
||||||
required: true
|
|
||||||
default: "v3.16.1"
|
|
||||||
deploy_production:
|
|
||||||
description: "Tag image for production deployment"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
deploy_staging:
|
|
||||||
description: "Tag image for staging deployment"
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
ECR_REGION: ${{ vars.ECR_REGION }}
|
|
||||||
# ECR settings are sourced from repository/environment variables for portability across envs/forks
|
|
||||||
ECR_REGISTRY: ${{ vars.ECR_REGISTRY }}
|
|
||||||
ECR_REPOSITORY: ${{ vars.ECR_REPOSITORY }}
|
|
||||||
DOCKERFILE: apps/web/Dockerfile
|
|
||||||
CONTEXT: .
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
name: Build and Push
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 45
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
|
|
||||||
- name: Validate image tag input
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
|
||||||
echo "❌ Image tag is required (non-empty)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if (( ${#IMAGE_TAG} > 128 )); then
|
|
||||||
echo "❌ Image tag must be at most 128 characters."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ ! "${IMAGE_TAG}" =~ ^[a-z0-9._-]+$ ]]; then
|
|
||||||
echo "❌ Image tag may only contain lowercase letters, digits, '.', '_' and '-'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [[ "${IMAGE_TAG}" =~ ^[.-] || "${IMAGE_TAG}" =~ [.-]$ ]]; then
|
|
||||||
echo "❌ Image tag must not start or end with '.' or '-'."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Validate required variables
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
|
||||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
|
||||||
ECR_REGION: ${{ env.ECR_REGION }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ -z "${ECR_REGISTRY}" || -z "${ECR_REPOSITORY}" || -z "${ECR_REGION}" ]]; then
|
|
||||||
echo "ECR_REGION, ECR_REGISTRY and ECR_REPOSITORY must be set via repository or environment variables (Settings → Variables)."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Update package.json version
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Remove 'v' prefix if present (e.g., v3.16.1 -> 3.16.1)
|
|
||||||
VERSION="${IMAGE_TAG#v}"
|
|
||||||
|
|
||||||
# Validate SemVer format (major.minor.patch with optional prerelease and build metadata)
|
|
||||||
if [[ ! "${VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
|
||||||
echo "❌ Error: Invalid version format after extraction. Must be SemVer (e.g., 1.2.3, 1.2.3-alpha, 1.2.3+build.1)"
|
|
||||||
echo "Original input: ${IMAGE_TAG}"
|
|
||||||
echo "Extracted version: ${VERSION}"
|
|
||||||
echo "Expected format: MAJOR.MINOR.PATCH[-PRERELEASE][+BUILDMETADATA]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ Valid SemVer format detected: ${VERSION}"
|
|
||||||
echo "Updating package.json version to: ${VERSION}"
|
|
||||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${VERSION}\"/" ./apps/web/package.json
|
|
||||||
cat ./apps/web/package.json | grep version
|
|
||||||
|
|
||||||
- name: Build tag list
|
|
||||||
id: tags
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
IMAGE_TAG: ${{ inputs.image_tag }}
|
|
||||||
DEPLOY_PRODUCTION: ${{ inputs.deploy_production }}
|
|
||||||
DEPLOY_STAGING: ${{ inputs.deploy_staging }}
|
|
||||||
ECR_REGISTRY: ${{ env.ECR_REGISTRY }}
|
|
||||||
ECR_REPOSITORY: ${{ env.ECR_REPOSITORY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Start with the base image tag
|
|
||||||
TAGS="${ECR_REGISTRY}/${ECR_REPOSITORY}:${IMAGE_TAG}"
|
|
||||||
|
|
||||||
# Add production tag if requested
|
|
||||||
if [[ "${DEPLOY_PRODUCTION}" == "true" ]]; then
|
|
||||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:production"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Add staging tag if requested
|
|
||||||
if [[ "${DEPLOY_STAGING}" == "true" ]]; then
|
|
||||||
TAGS="${TAGS}\n${ECR_REGISTRY}/${ECR_REPOSITORY}:staging"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Output for debugging
|
|
||||||
echo "Generated tags:"
|
|
||||||
echo -e "${TAGS}"
|
|
||||||
|
|
||||||
# Set output for next step (escape newlines for GitHub Actions)
|
|
||||||
{
|
|
||||||
echo "tags<<EOF"
|
|
||||||
echo -e "${TAGS}"
|
|
||||||
echo "EOF"
|
|
||||||
} >> "${GITHUB_OUTPUT}"
|
|
||||||
|
|
||||||
- name: Configure AWS credentials (OIDC)
|
|
||||||
uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a
|
|
||||||
with:
|
|
||||||
role-to-assume: ${{ secrets.AWS_ECR_PUSH_ROLE_ARN }}
|
|
||||||
aws-region: ${{ env.ECR_REGION }}
|
|
||||||
|
|
||||||
- name: Log in to Amazon ECR
|
|
||||||
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076
|
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
|
||||||
|
|
||||||
- name: Build and push image (Depot remote builder)
|
|
||||||
uses: depot/build-push-action@636daae76684e38c301daa0c5eca1c095b24e780 # v1.14.0
|
|
||||||
with:
|
|
||||||
project: tw0fqmsx3c
|
|
||||||
token: ${{ secrets.DEPOT_PROJECT_TOKEN }}
|
|
||||||
context: ${{ env.CONTEXT }}
|
|
||||||
file: ${{ env.DOCKERFILE }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.tags.outputs.tags }}
|
|
||||||
secrets: |
|
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
@@ -6,14 +6,12 @@ on:
|
|||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
actions: read
|
actions: read
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Dependency Review Action
|
||||||
|
#
|
||||||
|
# This Action will scan dependency manifest files that change as part of a Pull Request,
|
||||||
|
# surfacing known-vulnerable versions of the packages declared or updated in the PR.
|
||||||
|
# Once installed, if the workflow run is marked as required,
|
||||||
|
# PRs introducing known-vulnerable packages will be blocked from merging.
|
||||||
|
#
|
||||||
|
# Source repository: https://github.com/actions/dependency-review-action
|
||||||
|
name: 'Dependency Review'
|
||||||
|
on: [pull_request]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dependency-review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: 'Checkout Repository'
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: 'Dependency Review'
|
||||||
|
uses: actions/dependency-review-action@38ecb5b593bf0eb19e335c03f97670f792489a8b # v4.7.0
|
||||||
@@ -37,22 +37,17 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
contents: read
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
helmfile-deploy:
|
helmfile-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
|
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
uses: tailscale/github-action@v3
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
@@ -71,7 +66,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
AWS_REGION: eu-central-1
|
AWS_REGION: eu-central-1
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Production
|
name: Deploy Formbricks Cloud Production
|
||||||
if: inputs.ENVIRONMENT == 'production'
|
if: inputs.ENVIRONMENT == 'production'
|
||||||
env:
|
env:
|
||||||
@@ -89,7 +84,7 @@ jobs:
|
|||||||
helmfile-auto-init: "false"
|
helmfile-auto-init: "false"
|
||||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
- uses: helmfile/helmfile-action@712000e3d4e28c72778ecc53857746082f555ef3 # v2.0.4
|
- uses: helmfile/helmfile-action@v2
|
||||||
name: Deploy Formbricks Cloud Staging
|
name: Deploy Formbricks Cloud Staging
|
||||||
if: inputs.ENVIRONMENT == 'staging'
|
if: inputs.ENVIRONMENT == 'staging'
|
||||||
env:
|
env:
|
||||||
@@ -111,16 +106,15 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||||
ENVIRONMENT: ${{ inputs.ENVIRONMENT }}
|
|
||||||
run: |
|
run: |
|
||||||
# Set hostname based on environment
|
# Set hostname based on environment
|
||||||
if [[ "$ENVIRONMENT" == "production" ]]; then
|
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||||
PURGE_HOST="app.formbricks.com"
|
PURGE_HOST="app.formbricks.com"
|
||||||
else
|
else
|
||||||
PURGE_HOST="stage.app.formbricks.com"
|
PURGE_HOST="stage.app.formbricks.com"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: $ENVIRONMENT, zone: $CF_ZONE_ID)"
|
echo "Purging Cloudflare cache for host: $PURGE_HOST (environment: ${{ inputs.ENVIRONMENT }}, zone: $CF_ZONE_ID)"
|
||||||
|
|
||||||
# Prepare JSON payload for selective cache purge
|
# Prepare JSON payload for selective cache purge
|
||||||
json_payload=$(cat << EOF
|
json_payload=$(cat << EOF
|
||||||
|
|||||||
@@ -39,68 +39,42 @@ jobs:
|
|||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@v4.2.2
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
uses: docker/build-push-action@v6
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./apps/web/Dockerfile
|
file: ./apps/web/Dockerfile
|
||||||
push: false
|
push: false
|
||||||
load: true
|
load: true
|
||||||
tags: formbricks-test:${{ env.GITHUB_SHA }}
|
tags: formbricks-test:${{ github.sha }}
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
secrets: |
|
secrets: |
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
- name: Verify and Initialize PostgreSQL
|
- name: Verify PostgreSQL Connection
|
||||||
run: |
|
run: |
|
||||||
echo "Verifying PostgreSQL connection..."
|
echo "Verifying PostgreSQL connection..."
|
||||||
# Install PostgreSQL client to test connection
|
# Install PostgreSQL client to test connection
|
||||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
# Test connection using psql with timeout and proper error handling
|
# Test connection using psql
|
||||||
echo "Testing PostgreSQL connection with 30 second timeout..."
|
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||||
if timeout 30 bash -c 'until PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" >/dev/null 2>&1; do
|
|
||||||
echo "Waiting for PostgreSQL to be ready..."
|
|
||||||
sleep 2
|
|
||||||
done'; then
|
|
||||||
echo "✅ PostgreSQL connection successful"
|
|
||||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "SELECT version();"
|
|
||||||
|
|
||||||
# Enable necessary extensions that might be required by migrations
|
|
||||||
echo "Enabling required PostgreSQL extensions..."
|
|
||||||
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "CREATE EXTENSION IF NOT EXISTS vector;" || echo "Vector extension already exists or not available"
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "❌ PostgreSQL connection failed after 30 seconds"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Show network configuration
|
# Show network configuration
|
||||||
echo "Network configuration:"
|
echo "Network configuration:"
|
||||||
|
ip addr show
|
||||||
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||||
|
|
||||||
- name: Test Docker Image with Health Check
|
- name: Test Docker Image with Health Check
|
||||||
shell: bash
|
shell: bash
|
||||||
env:
|
|
||||||
GITHUB_SHA: ${{ github.sha }}
|
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "🧪 Testing if the Docker image starts correctly..."
|
echo "🧪 Testing if the Docker image starts correctly..."
|
||||||
|
|
||||||
@@ -112,12 +86,29 @@ jobs:
|
|||||||
$DOCKER_RUN_ARGS \
|
$DOCKER_RUN_ARGS \
|
||||||
-p 3000:3000 \
|
-p 3000:3000 \
|
||||||
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||||
-e ENCRYPTION_KEY="$DUMMY_ENCRYPTION_KEY" \
|
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||||
-d "formbricks-test:$GITHUB_SHA"
|
-d formbricks-test:${{ github.sha }}
|
||||||
|
|
||||||
# Start health check polling immediately (every 5 seconds for up to 5 minutes)
|
# Give it more time to start up
|
||||||
echo "🏥 Polling /health endpoint every 5 seconds for up to 5 minutes..."
|
echo "Waiting 45 seconds for application to start..."
|
||||||
MAX_RETRIES=60 # 60 attempts × 5 seconds = 5 minutes
|
sleep 45
|
||||||
|
|
||||||
|
# Check if the container is running
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||||
|
echo "❌ Container failed to start properly!"
|
||||||
|
docker logs formbricks-test
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Container started successfully!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try connecting to PostgreSQL from inside the container
|
||||||
|
echo "Testing PostgreSQL connection from inside container..."
|
||||||
|
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||||
|
|
||||||
|
# Try to access the health endpoint
|
||||||
|
echo "🏥 Testing /health endpoint..."
|
||||||
|
MAX_RETRIES=10
|
||||||
RETRY_COUNT=0
|
RETRY_COUNT=0
|
||||||
HEALTH_CHECK_SUCCESS=false
|
HEALTH_CHECK_SUCCESS=false
|
||||||
|
|
||||||
@@ -125,32 +116,38 @@ jobs:
|
|||||||
|
|
||||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
|
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||||
# Check if container is still running
|
|
||||||
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test 2>/dev/null)" != "true" ]; then
|
# Show container logs before each attempt to help debugging
|
||||||
echo "❌ Container stopped running after $((RETRY_COUNT * 5)) seconds!"
|
if [ $RETRY_COUNT -gt 1 ]; then
|
||||||
echo "📋 Container logs:"
|
echo "📋 Current container logs:"
|
||||||
docker logs formbricks-test
|
docker logs --tail 20 formbricks-test
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Show progress and diagnostic info every 12 attempts (1 minute intervals)
|
# Get detailed curl output for debugging
|
||||||
if [ $((RETRY_COUNT % 12)) -eq 0 ] || [ $RETRY_COUNT -eq 1 ]; then
|
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||||
echo "Health check attempt $RETRY_COUNT of $MAX_RETRIES ($(($RETRY_COUNT * 5)) seconds elapsed)..."
|
CURL_EXIT_CODE=$?
|
||||||
echo "📋 Recent container logs:"
|
|
||||||
docker logs --tail 10 formbricks-test
|
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||||
|
echo "Curl output: $HTTP_OUTPUT"
|
||||||
|
|
||||||
|
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||||
|
echo "Status code detected: $STATUS_CODE"
|
||||||
|
|
||||||
|
if [ "$STATUS_CODE" = "200" ]; then
|
||||||
|
echo "✅ Health check successful!"
|
||||||
|
HEALTH_CHECK_SUCCESS=true
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Try health endpoint with shorter timeout for faster polling
|
echo "Waiting 15 seconds before next attempt..."
|
||||||
# Use -f flag to make curl fail on HTTP error status codes (4xx, 5xx)
|
sleep 15
|
||||||
if curl -f -s -m 10 http://localhost:3000/health >/dev/null 2>&1; then
|
|
||||||
echo "✅ Health check successful after $((RETRY_COUNT * 5)) seconds!"
|
|
||||||
HEALTH_CHECK_SUCCESS=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Wait 5 seconds before next attempt
|
|
||||||
sleep 5
|
|
||||||
done
|
done
|
||||||
|
|
||||||
# Show full container logs for debugging
|
# Show full container logs for debugging
|
||||||
@@ -163,7 +160,7 @@ jobs:
|
|||||||
|
|
||||||
# Exit with failure if health check did not succeed
|
# Exit with failure if health check did not succeed
|
||||||
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||||
echo "❌ Health check failed after $((MAX_RETRIES * 5)) seconds (5 minutes)"
|
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
name: Docker Security Scan
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "0 2 * * *" # Daily at 2 AM UTC
|
|
||||||
workflow_dispatch:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["Docker Release to Github"]
|
|
||||||
types: [completed]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: read
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
scan:
|
|
||||||
name: Vulnerability Scan
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner
|
|
||||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout (for SARIF fingerprinting only)
|
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
||||||
with:
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Determine ref and commit for upload
|
|
||||||
id: gitref
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
EVENT_NAME: ${{ github.event_name }}
|
|
||||||
HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
|
||||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
if [[ "${EVENT_NAME}" == "workflow_run" ]]; then
|
|
||||||
echo "ref=refs/heads/${HEAD_BRANCH}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
else
|
|
||||||
echo "ref=${GITHUB_REF}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha=${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
- name: Log in to GitHub Container Registry
|
|
||||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Run Trivy vulnerability scanner
|
|
||||||
uses: aquasecurity/trivy-action@dc5a429b52fcf669ce959baa2c2dd26090d2a6c4 # v0.32.0
|
|
||||||
with:
|
|
||||||
image-ref: "ghcr.io/${{ github.repository }}:latest"
|
|
||||||
format: "sarif"
|
|
||||||
output: "trivy-results.sarif"
|
|
||||||
severity: "CRITICAL,HIGH,MEDIUM,LOW"
|
|
||||||
|
|
||||||
- name: Upload Trivy scan results to GitHub Security tab
|
|
||||||
uses: github/codeql-action/upload-sarif@a4e1a019f5e24960714ff6296aee04b736cbc3cf # v3.29.6
|
|
||||||
if: ${{ always() }}
|
|
||||||
with:
|
|
||||||
sarif_file: "trivy-results.sarif"
|
|
||||||
ref: ${{ steps.gitref.outputs.ref }}
|
|
||||||
sha: ${{ steps.gitref.outputs.sha }}
|
|
||||||
category: "trivy-container-scan"
|
|
||||||
@@ -7,13 +7,12 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker-build:
|
docker-build:
|
||||||
name: Build & release docker image
|
name: Build & release docker image
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
uses: ./.github/workflows/release-docker-github.yml
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
with:
|
with:
|
||||||
@@ -21,9 +20,6 @@ jobs:
|
|||||||
|
|
||||||
helm-chart-release:
|
helm-chart-release:
|
||||||
name: Release Helm Chart
|
name: Release Helm Chart
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
uses: ./.github/workflows/release-helm-chart.yml
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
needs:
|
needs:
|
||||||
@@ -33,9 +29,6 @@ jobs:
|
|||||||
|
|
||||||
deploy-formbricks-cloud:
|
deploy-formbricks-cloud:
|
||||||
name: Deploy Helm Chart to Formbricks Cloud
|
name: Deploy Helm Chart to Formbricks Cloud
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||||
needs:
|
needs:
|
||||||
@@ -43,6 +36,27 @@ jobs:
|
|||||||
- helm-chart-release
|
- helm-chart-release
|
||||||
with:
|
with:
|
||||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||||
|
|
||||||
|
upload-sentry-sourcemaps:
|
||||||
|
name: Upload Sentry Sourcemaps
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
needs:
|
||||||
|
- docker-build
|
||||||
|
- deploy-formbricks-cloud
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Upload Sentry Sourcemaps
|
||||||
|
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||||
|
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||||
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
environment: ${{ env.ENVIRONMENT }}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ jobs:
|
|||||||
# with sigstore/fulcio when running outside of PRs.
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||||
|
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
@@ -39,40 +41,34 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate SemVer version from branch or tag
|
- name: Generate SemVer version from branch or tag
|
||||||
id: generate_version
|
id: generate_version
|
||||||
env:
|
|
||||||
REF_NAME: ${{ github.ref_name }}
|
|
||||||
REF_TYPE: ${{ github.ref_type }}
|
|
||||||
run: |
|
run: |
|
||||||
# Get reference name and type from environment variables
|
# Get reference name and type
|
||||||
|
REF_NAME="${{ github.ref_name }}"
|
||||||
|
REF_TYPE="${{ github.ref_type }}"
|
||||||
|
|
||||||
echo "Reference type: $REF_TYPE"
|
echo "Reference type: $REF_TYPE"
|
||||||
echo "Reference name: $REF_NAME"
|
echo "Reference name: $REF_NAME"
|
||||||
|
|
||||||
# Create unique timestamped version for testing sourcemap resolution
|
|
||||||
TIMESTAMP=$(date +%s)
|
|
||||||
|
|
||||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||||
# If running from a tag, use the tag name + timestamp
|
# If running from a tag, use the tag name
|
||||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||||
BASE_VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||||
VERSION="${BASE_VERSION}-${TIMESTAMP}"
|
echo "Using SemVer tag: $VERSION"
|
||||||
echo "Using SemVer tag with timestamp: $VERSION"
|
|
||||||
else
|
else
|
||||||
# Tag is not SemVer, treat as prerelease
|
# 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')
|
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}"
|
VERSION="0.0.0-$SANITIZED_TAG"
|
||||||
echo "Using tag as prerelease with timestamp: $VERSION"
|
echo "Using tag as prerelease: $VERSION"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Running from branch, use branch name as prerelease + timestamp
|
# Running from branch, use branch name as prerelease
|
||||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
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}"
|
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||||
echo "Using branch as prerelease with timestamp: $VERSION"
|
echo "Using branch as prerelease: $VERSION"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
@@ -84,6 +80,15 @@ jobs:
|
|||||||
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.VERSION }}\"/" ./apps/web/package.json
|
||||||
cat ./apps/web/package.json | grep version
|
cat ./apps/web/package.json | grep version
|
||||||
|
|
||||||
|
- name: Set Sentry environment in .env
|
||||||
|
run: |
|
||||||
|
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||||
|
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||||
|
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||||
|
else
|
||||||
|
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
- name: Set up Depot CLI
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||||
|
|
||||||
@@ -110,9 +115,6 @@ jobs:
|
|||||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=raw,value=${{ env.VERSION }}
|
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
@@ -131,9 +133,21 @@ jobs:
|
|||||||
secrets: |
|
secrets: |
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
|
|
||||||
|
- name: Extract image info for sourcemap upload
|
||||||
|
id: extract_image_info
|
||||||
|
run: |
|
||||||
|
# Use the first readable tag from metadata action output
|
||||||
|
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
||||||
|
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
# Use the generated version for Sentry release
|
||||||
|
RELEASE_VERSION="$VERSION"
|
||||||
|
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "Docker image: $DOCKER_IMAGE"
|
||||||
|
echo "Release version: $RELEASE_VERSION"
|
||||||
|
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
@@ -148,4 +162,26 @@ jobs:
|
|||||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||||
# This step uses the identity token to provision an ephemeral certificate
|
# This step uses the identity token to provision an ephemeral certificate
|
||||||
# against the sigstore community Fulcio instance.
|
# against the sigstore community Fulcio instance.
|
||||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes "{}@${DIGEST}"
|
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||||
|
|
||||||
|
upload-sentry-sourcemaps:
|
||||||
|
name: Upload Sentry Sourcemaps
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Upload Sentry Sourcemaps
|
||||||
|
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
||||||
|
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
||||||
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
environment: staging
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ on:
|
|||||||
workflow_call:
|
workflow_call:
|
||||||
inputs:
|
inputs:
|
||||||
IS_PRERELEASE:
|
IS_PRERELEASE:
|
||||||
description: "Whether this is a prerelease (affects latest tag)"
|
description: 'Whether this is a prerelease (affects latest tag)'
|
||||||
required: false
|
required: false
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
@@ -26,9 +26,6 @@ env:
|
|||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -55,20 +52,9 @@ jobs:
|
|||||||
id: extract_release_tag
|
id: extract_release_tag
|
||||||
run: |
|
run: |
|
||||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||||
TAG="$GITHUB_REF"
|
TAG=${{ github.ref }}
|
||||||
TAG=${TAG#refs/tags/v}
|
TAG=${TAG#refs/tags/v}
|
||||||
|
|
||||||
# Validate the extracted tag format
|
|
||||||
if [[ ! "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
|
||||||
echo "❌ Error: Invalid release tag format after extraction. Must be semver (e.g., 1.2.3, 1.2.3-alpha)"
|
|
||||||
echo "Original ref: $GITHUB_REF"
|
|
||||||
echo "Extracted tag: $TAG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Safely add to environment variables
|
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
|
||||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
echo "Using tag-based version: $TAG"
|
echo "Using tag-based version: $TAG"
|
||||||
|
|
||||||
@@ -109,7 +95,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
# Only tag as 'latest' for stable releases (not prereleases)
|
# Only tag as 'latest' for stable releases (not prereleases)
|
||||||
type=raw,value=latest,enable=${{ !inputs.IS_PRERELEASE }}
|
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
|
||||||
|
|
||||||
# Build and push Docker image with Buildx (don't push on PR)
|
# Build and push Docker image with Buildx (don't push on PR)
|
||||||
# https://github.com/docker/build-push-action
|
# https://github.com/docker/build-push-action
|
||||||
@@ -128,7 +114,6 @@ jobs:
|
|||||||
secrets: |
|
secrets: |
|
||||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
|
|||||||
@@ -26,23 +26,8 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Validate input version
|
- name: Extract release version
|
||||||
env:
|
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||||
INPUT_VERSION: ${{ inputs.VERSION }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
# Validate input version format (expects clean semver without 'v' prefix)
|
|
||||||
if [[ ! "$INPUT_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
|
||||||
echo "❌ Error: Invalid version format. Must be clean semver (e.g., 1.2.3, 1.2.3-alpha)"
|
|
||||||
echo "Expected: clean version without 'v' prefix"
|
|
||||||
echo "Provided: $INPUT_VERSION"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Store validated version in environment variable
|
|
||||||
echo "VERSION<<EOF" >> $GITHUB_ENV
|
|
||||||
echo "$INPUT_VERSION" >> $GITHUB_ENV
|
|
||||||
echo "EOF" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Set up Helm
|
- name: Set up Helm
|
||||||
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||||
@@ -50,18 +35,15 @@ jobs:
|
|||||||
version: latest
|
version: latest
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
env:
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
GITHUB_ACTOR: ${{ github.actor }}
|
|
||||||
run: printf '%s' "$GITHUB_TOKEN" | helm registry login ghcr.io --username "$GITHUB_ACTOR" --password-stdin
|
|
||||||
|
|
||||||
- name: Install YQ
|
- name: Install YQ
|
||||||
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||||
|
|
||||||
- name: Update Chart.yaml with new version
|
- name: Update Chart.yaml with new version
|
||||||
run: |
|
run: |
|
||||||
yq -i ".version = \"$VERSION\"" helm-chart/Chart.yaml
|
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
yq -i ".appVersion = \"v$VERSION\"" helm-chart/Chart.yaml
|
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
|
|
||||||
- name: Package Helm chart
|
- name: Package Helm chart
|
||||||
run: |
|
run: |
|
||||||
@@ -69,4 +51,4 @@ jobs:
|
|||||||
|
|
||||||
- name: Push Helm chart to GitHub Container Registry
|
- name: Push Helm chart to GitHub Container Registry
|
||||||
run: |
|
run: |
|
||||||
helm push "formbricks-$VERSION.tgz" oci://ghcr.io/formbricks/helm-charts
|
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# This workflow uses actions that are not certified by GitHub. They are provided
|
||||||
|
# by a third-party and are governed by separate terms of service, privacy
|
||||||
|
# policy, and support documentation.
|
||||||
|
|
||||||
|
name: Scorecard supply-chain security
|
||||||
|
on:
|
||||||
|
# For Branch-Protection check. Only the default branch is supported. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection
|
||||||
|
branch_protection_rule:
|
||||||
|
# To guarantee Maintained check is occasionally updated. See
|
||||||
|
# https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained
|
||||||
|
schedule:
|
||||||
|
- cron: "17 17 * * 6"
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Declare default permissions as read only.
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
# Needed to upload the results to code-scanning dashboard.
|
||||||
|
security-events: write
|
||||||
|
# Needed to publish results and get a badge (see publish_results below).
|
||||||
|
id-token: write
|
||||||
|
# Add this permission
|
||||||
|
actions: write # Required for artifact upload
|
||||||
|
# Uncomment the permissions below if installing in a private repository.
|
||||||
|
# contents: read
|
||||||
|
# actions: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: "Checkout code"
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: "Run analysis"
|
||||||
|
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
# (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
|
||||||
|
# - you want to enable the Branch-Protection check on a *public* repository, or
|
||||||
|
# - you are installing Scorecard on a *private* repository
|
||||||
|
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional.
|
||||||
|
# repo_token: ${{ secrets.SCORECARD_TOKEN }}
|
||||||
|
|
||||||
|
# Public repositories:
|
||||||
|
# - Publish results to OpenSSF REST API for easy access by consumers
|
||||||
|
# - Allows the repository to include the Scorecard badge.
|
||||||
|
# - See https://github.com/ossf/scorecard-action#publishing-results.
|
||||||
|
# For private repositories:
|
||||||
|
# - `publish_results` will always be set to `false`, regardless
|
||||||
|
# of the value entered here.
|
||||||
|
publish_results: true
|
||||||
|
|
||||||
|
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||||
|
# format to the repository Actions tab.
|
||||||
|
- name: "Upload artifact"
|
||||||
|
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
|
||||||
|
with:
|
||||||
|
name: sarif
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
# Upload the results to GitHub's code scanning dashboard (optional).
|
||||||
|
# Commenting out will disable upload of results to your repo's Code Scanning dashboard
|
||||||
|
- name: "Upload to code-scanning"
|
||||||
|
uses: github/codeql-action/upload-sarif@b56ba49b26e50535fa1e7f7db0f4f7b4bf65d80d # v3.28.10
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
@@ -56,3 +56,11 @@ jobs:
|
|||||||
```
|
```
|
||||||
${{ steps.lint_pr_title.outputs.error_message }}
|
${{ steps.lint_pr_title.outputs.error_message }}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Delete a previous comment when the issue has been resolved
|
||||||
|
- if: ${{ steps.lint_pr_title.outputs.error_message == null }}
|
||||||
|
uses: marocchino/sticky-pull-request-comment@67d0dec7b07ed060a405f9b2a64b8ab319fdd7db # v2.9.2
|
||||||
|
with:
|
||||||
|
header: pr-title-lint-error
|
||||||
|
message: |
|
||||||
|
Thank you for following the naming conventions for pull request titles! 🙏
|
||||||
|
|||||||
@@ -14,14 +14,12 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- "infra/terraform/**"
|
- "infra/terraform/**"
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
terraform:
|
terraform:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -35,7 +33,7 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
- name: Tailscale
|
- name: Tailscale
|
||||||
uses: tailscale/github-action@84a3f23bb4d843bcf4da6cf824ec1be473daf4de # v3.2.3
|
uses: tailscale/github-action@v3
|
||||||
with:
|
with:
|
||||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||||
|
|||||||
@@ -27,18 +27,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Get source branch name
|
- name: Get source branch name
|
||||||
id: branch-name
|
id: branch-name
|
||||||
env:
|
|
||||||
RAW_BRANCH: ${{ github.head_ref }}
|
|
||||||
run: |
|
run: |
|
||||||
# Validate and sanitize branch name - only allow alphanumeric, dots, underscores, hyphens, and forward slashes
|
RAW_BRANCH="${{ github.head_ref }}"
|
||||||
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
|
||||||
|
|
||||||
# Additional validation - ensure branch name is not empty after sanitization
|
|
||||||
if [[ -z "$SOURCE_BRANCH" ]]; then
|
|
||||||
echo "❌ Error: Branch name is empty after sanitization"
|
|
||||||
echo "Original branch: $RAW_BRANCH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Safely add to environment variables using GitHub's recommended method
|
# Safely add to environment variables using GitHub's recommended method
|
||||||
# This prevents environment variable injection attacks
|
# This prevents environment variable injection attacks
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: Upload Sentry Sourcemaps (Manual)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
docker_image:
|
||||||
|
description: "Docker image to extract sourcemaps from"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
release_version:
|
||||||
|
description: "Release version (e.g., v1.2.3)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
tag_version:
|
||||||
|
description: "Docker image tag (leave empty to use release_version)"
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
upload-sourcemaps:
|
||||||
|
name: Upload Sourcemaps to Sentry
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set Docker Image
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ inputs.tag_version }}" ]; then
|
||||||
|
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.tag_version }}" >> $GITHUB_ENV
|
||||||
|
else
|
||||||
|
echo "DOCKER_IMAGE=${{ inputs.docker_image }}:${{ inputs.release_version }}" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload Sourcemaps to Sentry
|
||||||
|
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||||
|
with:
|
||||||
|
docker_image: ${{ env.DOCKER_IMAGE }}
|
||||||
|
release_version: ${{ inputs.release_version }}
|
||||||
|
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: "Welcome new contributors"
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types: opened
|
||||||
|
pull_request_target:
|
||||||
|
types: opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
welcome-message:
|
||||||
|
name: Welcoming New Users
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
if: github.event.action == 'opened'
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- uses: actions/first-interaction@3c71ce730280171fd1cfb57c00c774f8998586f7 # v1
|
||||||
|
with:
|
||||||
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
pr-message: |-
|
||||||
|
Thank you so much for making your first Pull Request and taking the time to improve Formbricks! 🚀🙏❤️
|
||||||
|
Feel free to join the conversation on [Github Discussions](https://github.com/formbricks/formbricks/discussions) if you need any help or have any questions. 😊
|
||||||
|
issue-message: |
|
||||||
|
Thank you for opening your first issue! 🙏❤️ One of our team members will review it and get back to you as soon as it possible. 😊
|
||||||
@@ -31,18 +31,6 @@
|
|||||||
{
|
{
|
||||||
"language": "pt-PT",
|
"language": "pt-PT",
|
||||||
"path": "./apps/web/locales/pt-PT.json"
|
"path": "./apps/web/locales/pt-PT.json"
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "ro-RO",
|
|
||||||
"path": "./apps/web/locales/ro-RO.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "ja-JP",
|
|
||||||
"path": "./apps/web/locales/ja-JP.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"language": "zh-Hans-CN",
|
|
||||||
"path": "./apps/web/locales/zh-Hans-CN.json"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"forceMode": "OVERRIDE"
|
"forceMode": "OVERRIDE"
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ The Open Source Qualtrics Alternative
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
<a href="https://github.com/formbricks/formbricks/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-AGPL-purple" alt="License"></a> <a href="https://github.com/formbricks/formbricks/stargazers"><img src="https://img.shields.io/github/stars/formbricks/formbricks?logo=github" alt="Github Stars"></a>
|
||||||
<a href="https://insights.linuxfoundation.org/project/formbricks"><img src="https://insights.linuxfoundation.org/api/badge/health-score?project=formbricks"></a>
|
|
||||||
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
<a href="https://news.ycombinator.com/item?id=32303986"><img src="https://img.shields.io/badge/Hacker%20News-122-%23FF6600" alt="Hacker News"></a>
|
||||||
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
<a href="[https://www.producthunt.com/products/formbricks](https://www.producthunt.com/posts/formbricks)"><img src="https://img.shields.io/badge/Product%20Hunt-455-orange?logo=producthunt&logoColor=%23fff" alt="Product Hunt"></a>
|
||||||
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
<a href="https://github.blog/2023-04-12-github-accelerator-our-first-cohort-and-whats-next/"><img src="https://img.shields.io/badge/2023-blue?logo=github&label=Github%20Accelerator" alt="Github Accelerator"></a>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { TolgeeProvider } from "@tolgee/react";
|
import { TolgeeProvider } from "@tolgee/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// Import translation data for Storybook
|
|
||||||
import enUSTranslations from "../../web/locales/en-US.json";
|
|
||||||
import "../../web/modules/ui/globals.css";
|
import "../../web/modules/ui/globals.css";
|
||||||
import { TolgeeBase } from "../../web/tolgee/shared";
|
import { TolgeeBase } from "../../web/tolgee/shared";
|
||||||
|
|
||||||
@@ -14,16 +12,7 @@ const withTolgee = (Story: any) => {
|
|||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
TolgeeProvider,
|
TolgeeProvider,
|
||||||
{
|
{ tolgee, fallback: "Loading", ssr: { language: "en", staticData: {} } },
|
||||||
tolgee,
|
|
||||||
fallback: "Loading",
|
|
||||||
ssr: {
|
|
||||||
language: "en-US",
|
|
||||||
staticData: {
|
|
||||||
"en-US": enUSTranslations,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
React.createElement(Story)
|
React.createElement(Story)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-7
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine3.22 AS base
|
FROM node:22-alpine3.21 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
@@ -30,13 +30,9 @@ COPY apps/web/scripts/docker/read-secrets.sh /tmp/read-secrets.sh
|
|||||||
RUN chmod +x /tmp/read-secrets.sh
|
RUN chmod +x /tmp/read-secrets.sh
|
||||||
|
|
||||||
# Increase Node.js memory limit as a regular build argument
|
# Increase Node.js memory limit as a regular build argument
|
||||||
ARG NODE_OPTIONS="--max_old_space_size=8192"
|
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||||
|
|
||||||
# Target architecture - automatically provided by Docker in multi-platform builds
|
|
||||||
# but needs explicit declaration for some build systems (like Depot)
|
|
||||||
ARG TARGETARCH
|
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -61,7 +57,6 @@ RUN pnpm build --filter=@formbricks/database
|
|||||||
# This mounts the secrets only during this build step without storing them in layers
|
# This mounts the secrets only during this build step without storing them in layers
|
||||||
RUN --mount=type=secret,id=database_url \
|
RUN --mount=type=secret,id=database_url \
|
||||||
--mount=type=secret,id=encryption_key \
|
--mount=type=secret,id=encryption_key \
|
||||||
--mount=type=secret,id=sentry_auth_token \
|
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
# Extract Prisma version
|
# Extract Prisma version
|
||||||
|
|||||||
+21
-3
@@ -45,11 +45,22 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("LandingSidebar component", () => {
|
describe("LandingSidebar component", () => {
|
||||||
const user = { id: "u1", name: "Alice", email: "alice@example.com" } as any;
|
const user = { id: "u1", name: "Alice", email: "alice@example.com", imageUrl: "" } as any;
|
||||||
const organization = { id: "o1", name: "orgOne" } as any;
|
const organization = { id: "o1", name: "orgOne" } as any;
|
||||||
|
const organizations = [
|
||||||
|
{ id: "o2", name: "betaOrg" },
|
||||||
|
{ id: "o1", name: "alphaOrg" },
|
||||||
|
] as any;
|
||||||
|
|
||||||
test("renders logo, avatar, and initial modal closed", () => {
|
test("renders logo, avatar, and initial modal closed", () => {
|
||||||
render(<LandingSidebar user={user} organization={organization} />);
|
render(
|
||||||
|
<LandingSidebar
|
||||||
|
isMultiOrgEnabled={false}
|
||||||
|
user={user}
|
||||||
|
organization={organization}
|
||||||
|
organizations={organizations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Formbricks logo
|
// Formbricks logo
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||||
@@ -60,7 +71,14 @@ describe("LandingSidebar component", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("clicking logout triggers signOut", async () => {
|
test("clicking logout triggers signOut", async () => {
|
||||||
render(<LandingSidebar user={user} organization={organization} />);
|
render(
|
||||||
|
<LandingSidebar
|
||||||
|
isMultiOrgEnabled={false}
|
||||||
|
user={user}
|
||||||
|
organization={organization}
|
||||||
|
organizations={organizations}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
// Open user dropdown by clicking on avatar trigger
|
// Open user dropdown by clicking on avatar trigger
|
||||||
const trigger = screen.getByTestId("avatar").parentElement;
|
const trigger = screen.getByTestId("avatar").parentElement;
|
||||||
|
|||||||
+93
-32
@@ -10,27 +10,48 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon } from "lucide-react";
|
import { ArrowUpRightIcon, ChevronRightIcon, LogOutIcon, PlusIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
interface LandingSidebarProps {
|
interface LandingSidebarProps {
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
|
organizations: TOrganization[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
export const LandingSidebar = ({
|
||||||
|
isMultiOrgEnabled,
|
||||||
|
user,
|
||||||
|
organization,
|
||||||
|
organizations,
|
||||||
|
}: LandingSidebarProps) => {
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState<boolean>(false);
|
||||||
|
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||||
|
router.push(`/organizations/${organizationId}/`);
|
||||||
|
};
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
{
|
{
|
||||||
label: t("common.documentation"),
|
label: t("common.documentation"),
|
||||||
@@ -40,6 +61,13 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentOrganizationId = organization?.id;
|
||||||
|
const currentOrganizationName = capitalizeFirstLetter(organization?.name);
|
||||||
|
|
||||||
|
const sortedOrganizations = useMemo(() => {
|
||||||
|
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [organizations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -52,28 +80,27 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className="w-full rounded-br-xl border-t p-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
className="w-full rounded-br-xl border-t py-4 pl-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<button
|
<div tabIndex={0} className={cn("flex cursor-pointer flex-row items-center space-x-3")}>
|
||||||
type="button"
|
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||||
className={cn("flex w-full cursor-pointer flex-row items-center gap-3 text-left")}
|
<>
|
||||||
aria-haspopup="menu">
|
<div>
|
||||||
<ProfileAvatar userId={user.id} />
|
<p
|
||||||
<div className="grow overflow-hidden">
|
title={user?.email}
|
||||||
<p
|
className={cn(
|
||||||
title={user?.email}
|
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||||
className={cn(
|
)}>
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
)}>
|
</p>
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
<p
|
||||||
</p>
|
title={capitalizeFirstLetter(organization?.name)}
|
||||||
<p
|
className="max-w-28 truncate text-sm text-slate-500">
|
||||||
title={capitalizeFirstLetter(organization?.name)}
|
{capitalizeFirstLetter(organization?.name)}
|
||||||
className="truncate text-sm text-slate-500">
|
</p>
|
||||||
{capitalizeFirstLetter(organization?.name)}
|
</div>
|
||||||
</p>
|
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||||
</div>
|
</>
|
||||||
<ChevronRightIcon className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")} />
|
</div>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -85,13 +112,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
{/* Dropdown Items */}
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||||
key={link.href}
|
|
||||||
id={link.href}
|
|
||||||
href={link.href}
|
|
||||||
target={link.target}
|
|
||||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}
|
|
||||||
className="flex w-full items-center">
|
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
{link.label}
|
{link.label}
|
||||||
@@ -100,6 +121,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await signOutWithAudit({
|
await signOutWithAudit({
|
||||||
@@ -114,6 +136,45 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* Organization Switch */}
|
||||||
|
|
||||||
|
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger className="rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p>{currentOrganizationName}</p>
|
||||||
|
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={currentOrganizationId}
|
||||||
|
onValueChange={(organizationId) =>
|
||||||
|
handleEnvironmentChangeByOrganization(organizationId)
|
||||||
|
}>
|
||||||
|
{sortedOrganizations.map((organization) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
value={organization.id}
|
||||||
|
className="cursor-pointer rounded-lg"
|
||||||
|
key={organization.id}>
|
||||||
|
{organization.name}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||||
|
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||||
|
<span>{t("common.create_new_organization")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-29
@@ -1,4 +1,3 @@
|
|||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
@@ -16,7 +15,6 @@ vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
vi.mock("@/lib/constants", () => ({
|
||||||
@@ -103,32 +101,16 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
AUDIT_LOG_ENABLED: true,
|
AUDIT_LOG_ENABLED: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/getPublicUrl", () => ({
|
|
||||||
getPublicDomain: vi.fn().mockReturnValue("http://localhost:3000"),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
vi.mock("@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar", () => ({
|
||||||
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
LandingSidebar: () => <div data-testid="landing-sidebar" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-and-org-switch", () => ({
|
|
||||||
ProjectAndOrgSwitch: () => <div data-testid="project-and-org-switch" />,
|
|
||||||
}));
|
|
||||||
vi.mock("@/modules/organization/lib/utils");
|
vi.mock("@/modules/organization/lib/utils");
|
||||||
vi.mock("@/lib/user/service");
|
vi.mock("@/lib/user/service");
|
||||||
vi.mock("@/lib/organization/service");
|
vi.mock("@/lib/organization/service");
|
||||||
vi.mock("@/lib/membership/service");
|
|
||||||
vi.mock("@/tolgee/server");
|
vi.mock("@/tolgee/server");
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
redirect: vi.fn(() => "REDIRECT_STUB"),
|
redirect: vi.fn(() => "REDIRECT_STUB"),
|
||||||
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
notFound: vi.fn(() => "NOT_FOUND_STUB"),
|
||||||
usePathname: vi.fn(() => "/organizations/org1"),
|
|
||||||
useRouter: vi.fn(() => ({
|
|
||||||
push: vi.fn(),
|
|
||||||
replace: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the React cache function
|
// Mock the React cache function
|
||||||
@@ -160,7 +142,6 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
const result = await Page({ params: { organizationId: "org1" } });
|
||||||
@@ -182,7 +163,6 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const result = await Page({ params: { organizationId: "org1" } });
|
const result = await Page({ params: { organizationId: "org1" } });
|
||||||
@@ -193,16 +173,10 @@ describe("Page component", () => {
|
|||||||
test("renders header and sidebar for authenticated user", async () => {
|
test("renders header and sidebar for authenticated user", async () => {
|
||||||
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
vi.mocked(getOrganizationAuth).mockResolvedValue({
|
||||||
session: { user: { id: "user1" } },
|
session: { user: { id: "user1" } },
|
||||||
organization: { id: "org1", billing: { plan: "free" } },
|
organization: { id: "org1" },
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
vi.mocked(getUser).mockResolvedValue({ id: "user1", name: "Test User" } as any);
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([{ id: "org1", name: "Org One" } as any]);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
role: "member",
|
|
||||||
} as any);
|
|
||||||
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
vi.mocked(getTranslate).mockResolvedValue((props: any) =>
|
||||||
typeof props === "string" ? props : props.key || ""
|
typeof props === "string" ? props : props.key || ""
|
||||||
);
|
);
|
||||||
@@ -214,13 +188,11 @@ describe("Page component", () => {
|
|||||||
isPendingDowngrade: false,
|
isPendingDowngrade: false,
|
||||||
fallbackLevel: "live",
|
fallbackLevel: "live",
|
||||||
}),
|
}),
|
||||||
getLicenseFeatures: vi.fn().mockResolvedValue({ isMultiOrgEnabled: true }),
|
|
||||||
}));
|
}));
|
||||||
const { default: Page } = await import("./page");
|
const { default: Page } = await import("./page");
|
||||||
const element = await Page({ params: { organizationId: "org1" } });
|
const element = await Page({ params: { organizationId: "org1" } });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
expect(screen.getByTestId("landing-sidebar")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("project-and-org-switch")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
expect(screen.getByText("organizations.landing.no_projects_warning_title")).toBeInTheDocument();
|
||||||
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
expect(screen.getByText("organizations.landing.no_projects_warning_subtitle")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
@@ -26,38 +22,24 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const { features } = await getEnterpriseLicense();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const { isMember } = getAccessFlags(membership?.role);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
<LandingSidebar user={user} organization={organization} />
|
<LandingSidebar
|
||||||
|
user={user}
|
||||||
|
organization={organization}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
organizations={organizations}
|
||||||
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
<div className="p-6">
|
<Header
|
||||||
{/* we only need to render organization breadcrumb on this page, so we pass some default value without actually calculating them to ProjectAndOrgSwitch component */}
|
title={t("organizations.landing.no_projects_warning_title")}
|
||||||
<ProjectAndOrgSwitch
|
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||||
currentOrganizationId={organization.id}
|
/>
|
||||||
organizations={organizations}
|
|
||||||
projects={[]}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={0}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
isLicenseActive={false}
|
|
||||||
isOwnerOrManager={false}
|
|
||||||
isAccessControlAllowed={false}
|
|
||||||
isMember={isMember}
|
|
||||||
environments={[]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
|
||||||
<Header
|
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -62,7 +62,7 @@ describe("ProjectSettings component", () => {
|
|||||||
industry: "ind",
|
industry: "ind",
|
||||||
defaultBrandColor: "#fff",
|
defaultBrandColor: "#fff",
|
||||||
organizationTeams: [],
|
organizationTeams: [],
|
||||||
isAccessControlAllowed: false,
|
canDoRoleManagement: false,
|
||||||
userProjectsCount: 0,
|
userProjectsCount: 0,
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -42,7 +42,7 @@ interface ProjectSettingsProps {
|
|||||||
industry: TProjectConfigIndustry;
|
industry: TProjectConfigIndustry;
|
||||||
defaultBrandColor: string;
|
defaultBrandColor: string;
|
||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
canDoRoleManagement: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ export const ProjectSettings = ({
|
|||||||
industry,
|
industry,
|
||||||
defaultBrandColor,
|
defaultBrandColor,
|
||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
canDoRoleManagement = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
@@ -174,7 +174,7 @@ export const ProjectSettings = ({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isAccessControlAllowed && userProjectsCount > 0 && (
|
{canDoRoleManagement && userProjectsCount > 0 && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="teamIds"
|
name="teamIds"
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/constants", () => ({ DEFAULT_BRAND_COLOR: "#fff" }));
|
|||||||
// Mocks before component import
|
// Mocks before component import
|
||||||
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
vi.mock("@/app/(app)/(onboarding)/lib/onboarding", () => ({ getTeamsByOrganizationId: vi.fn() }));
|
||||||
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
vi.mock("@/lib/project/service", () => ({ getUserProjects: vi.fn() }));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getAccessControlPermission: vi.fn() }));
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({ getRoleManagementPermission: vi.fn() }));
|
||||||
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
vi.mock("@/modules/organization/lib/utils", () => ({ getOrganizationAuth: vi.fn() }));
|
||||||
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
vi.mock("@/tolgee/server", () => ({ getTranslate: () => Promise.resolve((key: string) => key) }));
|
||||||
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
|
||||||
@@ -61,7 +61,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce(null as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(false as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(false as any);
|
||||||
|
|
||||||
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
await expect(Page({ params, searchParams })).rejects.toThrow("common.organization_teams_not_found");
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([{ id: "p1" }] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
@@ -96,7 +96,7 @@ describe("ProjectSettingsPage", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
vi.mocked(getUserProjects).mockResolvedValueOnce([] as any);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValueOnce([{ id: "t1", name: "Team1" }] as any);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValueOnce(true as any);
|
vi.mocked(getRoleManagementPermission).mockResolvedValueOnce(true as any);
|
||||||
|
|
||||||
const element = await Page({ params, searchParams });
|
const element = await Page({ params, searchParams });
|
||||||
render(element as React.ReactElement);
|
render(element as React.ReactElement);
|
||||||
|
|||||||
+3
-3
@@ -2,7 +2,7 @@ import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboardin
|
|||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
@@ -41,7 +41,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
@@ -60,7 +60,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
industry={industry}
|
industry={industry}
|
||||||
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
defaultBrandColor={DEFAULT_BRAND_COLOR}
|
||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||||
|
DevEnvironmentBanner: ({ environment }: any) => (
|
||||||
|
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
// Mocks for dependencies
|
// Mocks for dependencies
|
||||||
vi.mock("@/modules/environments/lib/utils", () => ({
|
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||||
@@ -53,6 +58,7 @@ describe("SurveyEditorEnvironmentLayout", () => {
|
|||||||
render(result);
|
render(result);
|
||||||
|
|
||||||
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||||
|
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
||||||
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
user={user}
|
user={user}
|
||||||
organization={organization}>
|
organization={organization}>
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-screen flex-col">
|
||||||
|
<DevEnvironmentBanner environment={environment} />
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</EnvironmentIdBaseLayout>
|
</EnvironmentIdBaseLayout>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-clie
|
|||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -58,9 +58,9 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!isAccessControlAllowed) {
|
if (!canDoRoleManagement) {
|
||||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-8
@@ -24,17 +24,14 @@ export const ActionClassesTable = ({
|
|||||||
otherEnvActionClasses,
|
otherEnvActionClasses,
|
||||||
otherEnvironment,
|
otherEnvironment,
|
||||||
}: ActionClassesTableProps) => {
|
}: ActionClassesTableProps) => {
|
||||||
const [isActionDetailModalOpen, setIsActionDetailModalOpen] = useState(false);
|
const [isActionDetailModalOpen, setActionDetailModalOpen] = useState(false);
|
||||||
|
|
||||||
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
const [activeActionClass, setActiveActionClass] = useState<TActionClass>();
|
||||||
|
|
||||||
const handleOpenActionDetailModalClick = (
|
const handleOpenActionDetailModalClick = (e, actionClass: TActionClass) => {
|
||||||
e: React.MouseEvent<HTMLButtonElement>,
|
|
||||||
actionClass: TActionClass
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setActiveActionClass(actionClass);
|
setActiveActionClass(actionClass);
|
||||||
setIsActionDetailModalOpen(true);
|
setActionDetailModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +42,7 @@ export const ActionClassesTable = ({
|
|||||||
{actionClasses.length > 0 ? (
|
{actionClasses.length > 0 ? (
|
||||||
actionClasses.map((actionClass, index) => (
|
actionClasses.map((actionClass, index) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
onClick={(e) => {
|
||||||
handleOpenActionDetailModalClick(e, actionClass);
|
handleOpenActionDetailModalClick(e, actionClass);
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -66,7 +63,7 @@ export const ActionClassesTable = ({
|
|||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
open={isActionDetailModalOpen}
|
open={isActionDetailModalOpen}
|
||||||
setOpen={setIsActionDetailModalOpen}
|
setOpen={setActionDetailModalOpen}
|
||||||
actionClasses={actionClasses}
|
actionClasses={actionClasses}
|
||||||
actionClass={activeActionClass}
|
actionClass={activeActionClass}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
|
|||||||
+10
-8
@@ -70,13 +70,15 @@ export const ActionDetailModal = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalWithTabs
|
<>
|
||||||
open={open}
|
<ModalWithTabs
|
||||||
setOpen={setOpen}
|
open={open}
|
||||||
tabs={tabs}
|
setOpen={setOpen}
|
||||||
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
tabs={tabs}
|
||||||
label={actionClass.name}
|
icon={ACTION_TYPE_ICON_LOOKUP[actionClass.type]}
|
||||||
description={typeDescription()}
|
label={actionClass.name}
|
||||||
/>
|
description={typeDescription()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+26
-170
@@ -11,21 +11,6 @@ vi.mock("@/app/(app)/environments/[environmentId]/actions/actions", () => ({
|
|||||||
updateActionClassAction: vi.fn(),
|
updateActionClassAction: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock action utils
|
|
||||||
vi.mock("@/modules/survey/editor/lib/action-utils", () => ({
|
|
||||||
useActionClassKeys: vi.fn(() => ["existing-key"]),
|
|
||||||
createActionClassZodResolver: vi.fn(() => vi.fn()),
|
|
||||||
validatePermissions: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock action builder
|
|
||||||
vi.mock("@/modules/survey/editor/lib/action-builder", () => ({
|
|
||||||
buildActionObject: vi.fn((data, environmentId, t) => ({
|
|
||||||
...data,
|
|
||||||
environmentId,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock utils
|
// Mock utils
|
||||||
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
vi.mock("@/app/lib/actionClass/actionClass", () => ({
|
||||||
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
isValidCssSelector: vi.fn((selector) => selector !== "invalid-selector"),
|
||||||
@@ -39,7 +24,6 @@ vi.mock("@/modules/ui/components/button", () => ({
|
|||||||
</button>
|
</button>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
vi.mock("@/modules/ui/components/code-action-form", () => ({
|
||||||
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
CodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||||
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
<div data-testid="code-action-form" data-readonly={isReadOnly}>
|
||||||
@@ -47,7 +31,6 @@ vi.mock("@/modules/ui/components/code-action-form", () => ({
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||||
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
DeleteDialog: ({ open, setOpen, isDeleting, onDelete }: any) =>
|
||||||
open ? (
|
open ? (
|
||||||
@@ -60,26 +43,6 @@ vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
|||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/action-name-description-fields", () => ({
|
|
||||||
ActionNameDescriptionFields: ({ isReadOnly, nameInputId, descriptionInputId }: any) => (
|
|
||||||
<div data-testid="action-name-description-fields">
|
|
||||||
<input
|
|
||||||
data-testid={`name-input-${nameInputId}`}
|
|
||||||
placeholder="environments.actions.eg_clicked_download"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
defaultValue="Test Action"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
data-testid={`description-input-${descriptionInputId}`}
|
|
||||||
placeholder="environments.actions.user_clicked_download_button"
|
|
||||||
disabled={isReadOnly}
|
|
||||||
defaultValue="Test Description"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
vi.mock("@/modules/ui/components/no-code-action-form", () => ({
|
||||||
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
NoCodeActionForm: ({ isReadOnly }: { isReadOnly: boolean }) => (
|
||||||
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
<div data-testid="no-code-action-form" data-readonly={isReadOnly}>
|
||||||
@@ -93,23 +56,6 @@ vi.mock("lucide-react", () => ({
|
|||||||
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
TrashIcon: () => <div data-testid="trash-icon">Trash</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock react-hook-form
|
|
||||||
const mockHandleSubmit = vi.fn();
|
|
||||||
const mockForm = {
|
|
||||||
handleSubmit: mockHandleSubmit,
|
|
||||||
control: {},
|
|
||||||
formState: { errors: {} },
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.mock("react-hook-form", async () => {
|
|
||||||
const actual = await vi.importActual("react-hook-form");
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useForm: vi.fn(() => mockForm),
|
|
||||||
FormProvider: ({ children }: any) => <div>{children}</div>,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockSetOpen = vi.fn();
|
const mockSetOpen = vi.fn();
|
||||||
const mockActionClasses: TActionClass[] = [
|
const mockActionClasses: TActionClass[] = [
|
||||||
{
|
{
|
||||||
@@ -142,7 +88,6 @@ const createMockActionClass = (id: string, type: TActionClassType, name: string)
|
|||||||
describe("ActionSettingsTab", () => {
|
describe("ActionSettingsTab", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockHandleSubmit.mockImplementation((fn) => fn);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -160,9 +105,13 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
actionClass.name
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||||
|
actionClass.description
|
||||||
|
);
|
||||||
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
expect(screen.getByTestId("code-action-form")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
screen.getByText("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")
|
||||||
@@ -182,104 +131,18 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toHaveValue(
|
||||||
|
actionClass.name
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toHaveValue(
|
||||||
|
actionClass.description
|
||||||
|
);
|
||||||
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
expect(screen.getByTestId("no-code-action-form")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "common.save_changes" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /common.delete/ })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders correctly for other action types (fallback)", () => {
|
|
||||||
const actionClass = {
|
|
||||||
...createMockActionClass("auto1", "noCode", "Auto Action"),
|
|
||||||
type: "automatic" as any,
|
|
||||||
};
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("action-name-description-fields")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
|
||||||
)
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls utility functions on initialization", async () => {
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
|
||||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles successful form submission", async () => {
|
|
||||||
const { updateActionClassAction } = await import(
|
|
||||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
|
||||||
);
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
|
|
||||||
vi.mocked(updateActionClassAction).mockResolvedValue({ data: {} } as any);
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check that utility functions were called during component initialization
|
|
||||||
expect(actionUtilsMock.useActionClassKeys).toHaveBeenCalledWith(mockActionClasses);
|
|
||||||
expect(actionUtilsMock.createActionClassZodResolver).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles permission validation error", async () => {
|
|
||||||
const actionUtilsMock = await import("@/modules/survey/editor/lib/action-utils");
|
|
||||||
vi.mocked(actionUtilsMock.validatePermissions).mockImplementation(() => {
|
|
||||||
throw new Error("Not authorized");
|
|
||||||
});
|
|
||||||
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitButton = screen.getByRole("button", { name: "common.save_changes" });
|
|
||||||
|
|
||||||
mockHandleSubmit.mockImplementation((fn) => (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
return fn({ name: "Test", type: "noCode" });
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(submitButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith("Not authorized");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles successful deletion", async () => {
|
test("handles successful deletion", async () => {
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
||||||
const { deleteActionClassAction } = await import(
|
const { deleteActionClassAction } = await import(
|
||||||
@@ -346,16 +209,17 @@ describe("ActionSettingsTab", () => {
|
|||||||
actionClass={actionClass}
|
actionClass={actionClass}
|
||||||
actionClasses={mockActionClasses}
|
actionClasses={mockActionClasses}
|
||||||
setOpen={mockSetOpen}
|
setOpen={mockSetOpen}
|
||||||
isReadOnly={true}
|
isReadOnly={true} // Set to read-only
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeDisabled();
|
// Use getByPlaceholderText or getByLabelText now that Input isn't mocked
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeDisabled();
|
expect(screen.getByPlaceholderText("environments.actions.eg_clicked_download")).toBeDisabled();
|
||||||
|
expect(screen.getByPlaceholderText("environments.actions.user_clicked_download_button")).toBeDisabled();
|
||||||
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
expect(screen.getByTestId("no-code-action-form")).toHaveAttribute("data-readonly", "true");
|
||||||
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "common.save_changes" })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument();
|
expect(screen.getByRole("link", { name: "common.read_docs" })).toBeInTheDocument(); // Docs link still visible
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prevents delete when read-only", async () => {
|
test("prevents delete when read-only", async () => {
|
||||||
@@ -364,6 +228,7 @@ describe("ActionSettingsTab", () => {
|
|||||||
"@/app/(app)/environments/[environmentId]/actions/actions"
|
"@/app/(app)/environments/[environmentId]/actions/actions"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Render with isReadOnly=true, but simulate a delete attempt
|
||||||
render(
|
render(
|
||||||
<ActionSettingsTab
|
<ActionSettingsTab
|
||||||
actionClass={actionClass}
|
actionClass={actionClass}
|
||||||
@@ -373,6 +238,12 @@ describe("ActionSettingsTab", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Try to open and confirm delete dialog (buttons won't exist, so we simulate the flow)
|
||||||
|
// This test primarily checks the logic within handleDeleteAction if it were called.
|
||||||
|
// A better approach might be to export handleDeleteAction for direct testing,
|
||||||
|
// but for now, we assume the UI prevents calling it.
|
||||||
|
|
||||||
|
// We can assert that the delete button isn't there to prevent the flow
|
||||||
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: /common.delete/ })).not.toBeInTheDocument();
|
||||||
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
expect(deleteActionClassAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -391,19 +262,4 @@ describe("ActionSettingsTab", () => {
|
|||||||
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
expect(docsLink).toHaveAttribute("href", "https://formbricks.com/docs/actions/no-code");
|
||||||
expect(docsLink).toHaveAttribute("target", "_blank");
|
expect(docsLink).toHaveAttribute("target", "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses correct input IDs for ActionNameDescriptionFields", () => {
|
|
||||||
const actionClass = createMockActionClass("noCode1", "noCode", "No Code Action");
|
|
||||||
render(
|
|
||||||
<ActionSettingsTab
|
|
||||||
actionClass={actionClass}
|
|
||||||
actionClasses={mockActionClasses}
|
|
||||||
setOpen={mockSetOpen}
|
|
||||||
isReadOnly={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("name-input-actionNameSettingsInput")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("description-input-actionDescriptionSettingsInput")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
+120
-45
@@ -4,17 +4,14 @@ import {
|
|||||||
deleteActionClassAction,
|
deleteActionClassAction,
|
||||||
updateActionClassAction,
|
updateActionClassAction,
|
||||||
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
} from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||||
import { buildActionObject } from "@/modules/survey/editor/lib/action-builder";
|
import { isValidCssSelector } from "@/app/lib/actionClass/actionClass";
|
||||||
import {
|
|
||||||
createActionClassZodResolver,
|
|
||||||
useActionClassKeys,
|
|
||||||
validatePermissions,
|
|
||||||
} from "@/modules/survey/editor/lib/action-utils";
|
|
||||||
import { ActionNameDescriptionFields } from "@/modules/ui/components/action-name-description-fields";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
import { CodeActionForm } from "@/modules/ui/components/code-action-form";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import { FormControl, FormError, FormField, FormItem, FormLabel } from "@/modules/ui/components/form";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
import { NoCodeActionForm } from "@/modules/ui/components/no-code-action-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -22,7 +19,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { FormProvider, useForm } from "react-hook-form";
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
import { z } from "zod";
|
||||||
|
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||||
|
|
||||||
interface ActionSettingsTabProps {
|
interface ActionSettingsTabProps {
|
||||||
actionClass: TActionClass;
|
actionClass: TActionClass;
|
||||||
@@ -50,51 +48,63 @@ export const ActionSettingsTab = ({
|
|||||||
[actionClass.id, actionClasses]
|
[actionClass.id, actionClasses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const actionClassKeys = useActionClassKeys(actionClasses).filter((key) => key !== actionClass.key);
|
|
||||||
|
|
||||||
const form = useForm<TActionClassInput>({
|
const form = useForm<TActionClassInput>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
...restActionClass,
|
...restActionClass,
|
||||||
},
|
},
|
||||||
resolver: createActionClassZodResolver(actionClassNames, actionClassKeys, t),
|
resolver: zodResolver(
|
||||||
|
ZActionClassInput.superRefine((data, ctx) => {
|
||||||
|
if (data.name && actionClassNames.includes(data.name)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ["name"],
|
||||||
|
message: t("environments.actions.action_with_name_already_exists", { name: data.name }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, control } = form;
|
const { handleSubmit, control } = form;
|
||||||
|
|
||||||
const renderActionForm = () => {
|
|
||||||
if (actionClass.type === "code") {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CodeActionForm form={form} isReadOnly={true} />
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionClass.type === "noCode") {
|
|
||||||
return <NoCodeActionForm form={form} isReadOnly={isReadOnly} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<p className="text-sm text-slate-600">
|
|
||||||
{t("environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it")}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSubmit = async (data: TActionClassInput) => {
|
const onSubmit = async (data: TActionClassInput) => {
|
||||||
try {
|
try {
|
||||||
|
if (isReadOnly) {
|
||||||
|
throw new Error(t("common.you_are_not_authorised_to_perform_this_action"));
|
||||||
|
}
|
||||||
setIsUpdatingAction(true);
|
setIsUpdatingAction(true);
|
||||||
validatePermissions(isReadOnly, t);
|
|
||||||
const updatedAction = buildActionObject(data, actionClass.environmentId, t);
|
|
||||||
|
|
||||||
|
if (data.name && actionClassNames.includes(data.name)) {
|
||||||
|
throw new Error(t("environments.actions.action_with_name_already_exists", { name: data.name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.type === "noCode" &&
|
||||||
|
data.noCodeConfig?.type === "click" &&
|
||||||
|
data.noCodeConfig.elementSelector.cssSelector &&
|
||||||
|
!isValidCssSelector(data.noCodeConfig.elementSelector.cssSelector)
|
||||||
|
) {
|
||||||
|
throw new Error(t("environments.actions.invalid_css_selector"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedData: TActionClassInput = {
|
||||||
|
...data,
|
||||||
|
...(data.type === "noCode" &&
|
||||||
|
data.noCodeConfig?.type === "click" && {
|
||||||
|
noCodeConfig: {
|
||||||
|
...data.noCodeConfig,
|
||||||
|
elementSelector: {
|
||||||
|
cssSelector: data.noCodeConfig.elementSelector.cssSelector,
|
||||||
|
innerHtml: data.noCodeConfig.elementSelector.innerHtml,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
await updateActionClassAction({
|
await updateActionClassAction({
|
||||||
actionClassId: actionClass.id,
|
actionClassId: actionClass.id,
|
||||||
updatedAction: updatedAction,
|
updatedAction: updatedData,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@@ -113,7 +123,7 @@ export const ActionSettingsTab = ({
|
|||||||
router.refresh();
|
router.refresh();
|
||||||
toast.success(t("environments.actions.action_deleted_successfully"));
|
toast.success(t("environments.actions.action_deleted_successfully"));
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch {
|
} catch (error) {
|
||||||
toast.error(t("common.something_went_wrong_please_try_again"));
|
toast.error(t("common.something_went_wrong_please_try_again"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDeletingAction(false);
|
setIsDeletingAction(false);
|
||||||
@@ -125,14 +135,79 @@ export const ActionSettingsTab = ({
|
|||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
<div className="max-h-[400px] w-full space-y-4 overflow-y-auto">
|
||||||
<ActionNameDescriptionFields
|
<div className="grid w-full grid-cols-2 gap-x-4">
|
||||||
control={control}
|
<div className="col-span-1">
|
||||||
isReadOnly={isReadOnly}
|
<FormField
|
||||||
nameInputId="actionNameSettingsInput"
|
control={control}
|
||||||
descriptionInputId="actionDescriptionSettingsInput"
|
name="name"
|
||||||
/>
|
disabled={isReadOnly}
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="actionNameSettingsInput">
|
||||||
|
{actionClass.type === "noCode"
|
||||||
|
? t("environments.actions.what_did_your_user_do")
|
||||||
|
: t("environments.actions.display_name")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
{renderActionForm()}
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="actionNameSettingsInput"
|
||||||
|
{...field}
|
||||||
|
placeholder={t("environments.actions.eg_clicked_download")}
|
||||||
|
isInvalid={!!error?.message}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-span-1">
|
||||||
|
<FormField
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel htmlFor="actionDescriptionSettingsInput">
|
||||||
|
{t("common.description")}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="actionDescriptionSettingsInput"
|
||||||
|
{...field}
|
||||||
|
placeholder={t("environments.actions.user_clicked_download_button")}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionClass.type === "code" ? (
|
||||||
|
<>
|
||||||
|
<CodeActionForm form={form} isReadOnly={true} />
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{t("environments.actions.this_is_a_code_action_please_make_changes_in_your_code_base")}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : actionClass.type === "noCode" ? (
|
||||||
|
<NoCodeActionForm form={form} isReadOnly={isReadOnly} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-600">
|
||||||
|
{t(
|
||||||
|
"environments.actions.this_action_was_created_automatically_you_cannot_make_changes_to_it"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||||
|
|||||||
+85
-35
@@ -1,5 +1,3 @@
|
|||||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
|
||||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
@@ -7,11 +5,13 @@ import {
|
|||||||
getMonthlyActiveOrganizationPeopleCount,
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
|
getOrganizationsByUserId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team";
|
||||||
@@ -20,7 +20,11 @@ import type { Session } from "next-auth";
|
|||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
import { TMembership } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import {
|
||||||
|
TOrganization,
|
||||||
|
TOrganizationBilling,
|
||||||
|
TOrganizationBillingPlanLimits,
|
||||||
|
} from "@formbricks/types/organizations";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
@@ -31,12 +35,16 @@ vi.mock("@/lib/environment/service", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/organization/service", () => ({
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
getOrganizationByEnvironmentId: vi.fn(),
|
getOrganizationByEnvironmentId: vi.fn(),
|
||||||
|
getOrganizationsByUserId: vi.fn(),
|
||||||
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
getMonthlyActiveOrganizationPeopleCount: vi.fn(),
|
||||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/user/service", () => ({
|
vi.mock("@/lib/user/service", () => ({
|
||||||
getUser: vi.fn(),
|
getUser: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/lib/project/service", () => ({
|
||||||
|
getUserProjects: vi.fn(),
|
||||||
|
}));
|
||||||
vi.mock("@/lib/membership/service", () => ({
|
vi.mock("@/lib/membership/service", () => ({
|
||||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -45,7 +53,7 @@ vi.mock("@/lib/membership/utils", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
getOrganizationProjectsLimit: vi.fn(),
|
getOrganizationProjectsLimit: vi.fn(),
|
||||||
getAccessControlPermission: vi.fn(),
|
getRoleManagementPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
vi.mock("@/modules/ee/teams/lib/roles", () => ({
|
||||||
getProjectPermissionByUserId: vi.fn(),
|
getProjectPermissionByUserId: vi.fn(),
|
||||||
@@ -56,22 +64,6 @@ vi.mock("@/modules/ee/teams/team-list/lib/team", () => ({
|
|||||||
vi.mock("@/tolgee/server", () => ({
|
vi.mock("@/tolgee/server", () => ({
|
||||||
getTranslate: async () => (key: string) => key,
|
getTranslate: async () => (key: string) => key,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/organization", () => ({
|
|
||||||
getOrganizationsByUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/lib/project", () => ({
|
|
||||||
getProjectsByUserId: vi.fn(),
|
|
||||||
}));
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
project: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
organization: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
let mockIsFormbricksCloud = false;
|
let mockIsFormbricksCloud = false;
|
||||||
let mockIsDevelopment = false;
|
let mockIsDevelopment = false;
|
||||||
@@ -87,17 +79,21 @@ vi.mock("@/lib/constants", () => ({
|
|||||||
|
|
||||||
// Mock components
|
// Mock components
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/MainNavigation", () => ({
|
||||||
MainNavigation: ({ organizationTeams, isAccessControlAllowed }: any) => (
|
MainNavigation: ({ organizationTeams, canDoRoleManagement }: any) => (
|
||||||
<div data-testid="main-navigation">
|
<div data-testid="main-navigation">
|
||||||
MainNavigation
|
MainNavigation
|
||||||
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
<div data-testid="organization-teams">{JSON.stringify(organizationTeams || [])}</div>
|
||||||
<div data-testid="is-access-control-allowed">{isAccessControlAllowed?.toString() || "false"}</div>
|
<div data-testid="can-do-role-management">{canDoRoleManagement?.toString() || "false"}</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlBar", () => ({
|
||||||
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
TopControlBar: () => <div data-testid="top-control-bar">TopControlBar</div>,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||||
|
DevEnvironmentBanner: ({ environment }: { environment: TEnvironment }) =>
|
||||||
|
environment.type === "development" ? <div data-testid="dev-banner">DevEnvironmentBanner</div> : null,
|
||||||
|
}));
|
||||||
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
vi.mock("@/modules/ui/components/limits-reached-banner", () => ({
|
||||||
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
LimitsReachedBanner: () => <div data-testid="limits-banner">LimitsReachedBanner</div>,
|
||||||
}));
|
}));
|
||||||
@@ -117,6 +113,7 @@ const mockUser = {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -127,10 +124,12 @@ const mockUser = {
|
|||||||
const mockOrganization = {
|
const mockOrganization = {
|
||||||
id: "org-1",
|
id: "org-1",
|
||||||
name: "Test Org",
|
name: "Test Org",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
billing: {
|
billing: {
|
||||||
plan: "free",
|
stripeCustomerId: null,
|
||||||
limits: {},
|
limits: { monthly: { responses: null } } as unknown as TOrganizationBillingPlanLimits,
|
||||||
},
|
} as unknown as TOrganizationBilling,
|
||||||
} as unknown as TOrganization;
|
} as unknown as TOrganization;
|
||||||
|
|
||||||
const mockEnvironment: TEnvironment = {
|
const mockEnvironment: TEnvironment = {
|
||||||
@@ -193,11 +192,9 @@ describe("EnvironmentLayout", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||||
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
vi.mocked(getEnvironment).mockResolvedValue(mockEnvironment);
|
||||||
vi.mocked(getOrganizationsByUserId).mockResolvedValue([
|
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
|
||||||
{ id: mockOrganization.id, name: mockOrganization.name },
|
|
||||||
]);
|
|
||||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||||
vi.mocked(getProjectsByUserId).mockResolvedValue([{ id: mockProject.id, name: mockProject.name }]);
|
vi.mocked(getUserProjects).mockResolvedValue([mockProject]);
|
||||||
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
vi.mocked(getEnvironments).mockResolvedValue([mockEnvironment]);
|
||||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||||
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
vi.mocked(getMonthlyActiveOrganizationPeopleCount).mockResolvedValue(100);
|
||||||
@@ -205,7 +202,7 @@ describe("EnvironmentLayout", () => {
|
|||||||
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
vi.mocked(getOrganizationProjectsLimit).mockResolvedValue(null as any);
|
||||||
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
vi.mocked(getProjectPermissionByUserId).mockResolvedValue(mockProjectPermission);
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue(mockOrganizationTeams);
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(true);
|
vi.mocked(getRoleManagementPermission).mockResolvedValue(true);
|
||||||
mockIsDevelopment = false;
|
mockIsDevelopment = false;
|
||||||
mockIsFormbricksCloud = false;
|
mockIsFormbricksCloud = false;
|
||||||
});
|
});
|
||||||
@@ -244,6 +241,33 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("downgrade-banner")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders DevEnvironmentBanner in development environment", async () => {
|
||||||
|
const devEnvironment = { ...mockEnvironment, type: "development" as const };
|
||||||
|
vi.mocked(getEnvironment).mockResolvedValue(devEnvironment);
|
||||||
|
mockIsDevelopment = true;
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("dev-banner")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
test("renders LimitsReachedBanner in Formbricks Cloud", async () => {
|
||||||
mockIsFormbricksCloud = true;
|
mockIsFormbricksCloud = true;
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -291,6 +315,32 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
expect(screen.getByTestId("downgrade-banner")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("passes canDoRoleManagement props to MainNavigation", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
active: false,
|
||||||
|
isPendingDowngrade: false,
|
||||||
|
features: { isMultiOrgEnabled: false },
|
||||||
|
lastChecked: new Date(),
|
||||||
|
fallbackLevel: "live",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
const { EnvironmentLayout } = await import(
|
||||||
|
"@/app/(app)/environments/[environmentId]/components/EnvironmentLayout"
|
||||||
|
);
|
||||||
|
render(
|
||||||
|
await EnvironmentLayout({
|
||||||
|
environmentId: "env-1",
|
||||||
|
session: mockSession,
|
||||||
|
children: <div>Child Content</div>,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||||
|
expect(vi.mocked(getRoleManagementPermission)).toHaveBeenCalledWith(mockOrganization.billing.plan);
|
||||||
|
});
|
||||||
|
|
||||||
test("handles empty organizationTeams array", async () => {
|
test("handles empty organizationTeams array", async () => {
|
||||||
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
vi.mocked(getTeamsByOrganizationId).mockResolvedValue([]);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -343,8 +393,8 @@ describe("EnvironmentLayout", () => {
|
|||||||
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
expect(screen.getByTestId("organization-teams")).toHaveTextContent("[]");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", async () => {
|
test("handles canDoRoleManagement false", async () => {
|
||||||
vi.mocked(getAccessControlPermission).mockResolvedValue(false);
|
vi.mocked(getRoleManagementPermission).mockResolvedValue(false);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
@@ -366,7 +416,7 @@ describe("EnvironmentLayout", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("is-access-control-allowed")).toHaveTextContent("false");
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if user not found", async () => {
|
test("throws error if user not found", async () => {
|
||||||
@@ -430,7 +480,7 @@ describe("EnvironmentLayout", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("throws error if projects, environments or organizations not found", async () => {
|
test("throws error if projects, environments or organizations not found", async () => {
|
||||||
vi.mocked(getProjectsByUserId).mockResolvedValue(null as any);
|
vi.mocked(getUserProjects).mockResolvedValue(null as any);
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
await vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||||
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
getEnterpriseLicense: vi.fn().mockResolvedValue({
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { getOrganizationsByUserId } from "@/app/(app)/environments/[environmentId]/lib/organization";
|
|
||||||
import { getProjectsByUserId } from "@/app/(app)/environments/[environmentId]/lib/project";
|
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
import { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
@@ -10,14 +8,17 @@ import {
|
|||||||
getMonthlyActiveOrganizationPeopleCount,
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
getMonthlyOrganizationResponseCount,
|
getMonthlyOrganizationResponseCount,
|
||||||
getOrganizationByEnvironmentId,
|
getOrganizationByEnvironmentId,
|
||||||
|
getOrganizationsByUserId,
|
||||||
} from "@/lib/organization/service";
|
} from "@/lib/organization/service";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
|
||||||
getOrganizationProjectsLimit,
|
getOrganizationProjectsLimit,
|
||||||
|
getRoleManagementPermission,
|
||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-banner";
|
||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
@@ -50,22 +51,18 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
throw new Error(t("common.environment_not_found"));
|
throw new Error(t("common.environment_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
const [projects, environments, canDoRoleManagement] = await Promise.all([
|
||||||
if (!currentUserMembership) {
|
getUserProjects(user.id, organization.id),
|
||||||
throw new Error(t("common.membership_not_found"));
|
|
||||||
}
|
|
||||||
const membershipRole = currentUserMembership?.role;
|
|
||||||
|
|
||||||
const [projects, environments, isAccessControlAllowed] = await Promise.all([
|
|
||||||
getProjectsByUserId(user.id, currentUserMembership),
|
|
||||||
getEnvironments(environment.projectId),
|
getEnvironments(environment.projectId),
|
||||||
getAccessControlPermission(organization.billing.plan),
|
getRoleManagementPermission(organization.billing.plan),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!projects || !environments || !organizations) {
|
if (!projects || !environments || !organizations) {
|
||||||
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
throw new Error(t("environments.projects_environments_organizations_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
||||||
|
const membershipRole = currentUserMembership?.role;
|
||||||
const { isMember } = getAccessFlags(membershipRole);
|
const { isMember } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
const { features, lastChecked, isPendingDowngrade, active } = await getEnterpriseLicense();
|
||||||
@@ -90,17 +87,10 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
||||||
|
|
||||||
// Find the current project from the projects array
|
|
||||||
const project = projects.find((p) => p.id === environment.projectId);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isManager, isOwner } = getAccessFlags(membershipRole);
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
<div className="flex h-screen min-h-screen flex-col overflow-hidden">
|
||||||
|
<DevEnvironmentBanner environment={environment} />
|
||||||
|
|
||||||
{IS_FORMBRICKS_CLOUD && (
|
{IS_FORMBRICKS_CLOUD && (
|
||||||
<LimitsReachedBanner
|
<LimitsReachedBanner
|
||||||
organization={organization}
|
organization={organization}
|
||||||
@@ -122,29 +112,25 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
<MainNavigation
|
<MainNavigation
|
||||||
environment={environment}
|
environment={environment}
|
||||||
organization={organization}
|
organization={organization}
|
||||||
|
organizations={organizations}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
user={user}
|
user={user}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
isLicenseActive={active}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex-1 overflow-y-auto bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
environment={environment}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
currentOrganizationId={organization.id}
|
|
||||||
organizations={organizations}
|
|
||||||
currentProjectId={project.id}
|
|
||||||
projects={projects}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
isLicenseActive={active}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
projectPermission={projectPermission}
|
projectPermission={projectPermission}
|
||||||
/>
|
/>
|
||||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
<div className="mt-14">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+85
-11
@@ -1,4 +1,5 @@
|
|||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team";
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -51,6 +52,23 @@ vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|||||||
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
CreateOrganizationModal: ({ open }: { open: boolean }) =>
|
||||||
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
open ? <div data-testid="create-org-modal">Create Org Modal</div> : null,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/modules/projects/components/project-switcher", () => ({
|
||||||
|
ProjectSwitcher: ({
|
||||||
|
isCollapsed,
|
||||||
|
organizationTeams,
|
||||||
|
canDoRoleManagement,
|
||||||
|
}: {
|
||||||
|
isCollapsed: boolean;
|
||||||
|
organizationTeams: TOrganizationTeam[];
|
||||||
|
canDoRoleManagement: boolean;
|
||||||
|
}) => (
|
||||||
|
<div data-testid="project-switcher" data-collapsed={isCollapsed}>
|
||||||
|
Project Switcher
|
||||||
|
<div data-testid="organization-teams-count">{organizationTeams?.length || 0}</div>
|
||||||
|
<div data-testid="can-do-role-management">{canDoRoleManagement.toString()}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||||
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
ProfileAvatar: () => <div data-testid="profile-avatar">Avatar</div>,
|
||||||
}));
|
}));
|
||||||
@@ -93,6 +111,7 @@ const mockUser = {
|
|||||||
id: "user1",
|
id: "user1",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "http://example.com/avatar.png",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
@@ -138,7 +157,7 @@ const defaultProps = {
|
|||||||
membershipRole: "owner" as const,
|
membershipRole: "owner" as const,
|
||||||
organizationProjectsLimit: 5,
|
organizationProjectsLimit: 5,
|
||||||
isLicenseActive: true,
|
isLicenseActive: true,
|
||||||
isAccessControlAllowed: true,
|
canDoRoleManagement: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("MainNavigation", () => {
|
describe("MainNavigation", () => {
|
||||||
@@ -159,11 +178,13 @@ describe("MainNavigation", () => {
|
|||||||
|
|
||||||
test("renders expanded by default and collapses on toggle", async () => {
|
test("renders expanded by default and collapses on toggle", async () => {
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
const projectSwitcher = screen.getByTestId("project-switcher");
|
||||||
// Assuming the toggle button is the only one initially without an accessible name
|
// Assuming the toggle button is the only one initially without an accessible name
|
||||||
// A more specific selector like data-testid would be better if available.
|
// A more specific selector like data-testid would be better if available.
|
||||||
const toggleButton = screen.getByRole("button", { name: "" });
|
const toggleButton = screen.getByRole("button", { name: "" });
|
||||||
|
|
||||||
// Check initial state (expanded)
|
// Check initial state (expanded)
|
||||||
|
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||||
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
expect(screen.getByAltText("environments.formbricks_logo")).toBeInTheDocument();
|
||||||
// Check localStorage is not set initially after clear()
|
// Check localStorage is not set initially after clear()
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
expect(localStorage.getItem("isMainNavCollapsed")).toBeNull();
|
||||||
@@ -174,6 +195,7 @@ describe("MainNavigation", () => {
|
|||||||
// Check state after first toggle (collapsed)
|
// Check state after first toggle (collapsed)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check that the attribute eventually becomes true
|
// Check that the attribute eventually becomes true
|
||||||
|
expect(projectSwitcher).toHaveAttribute("data-collapsed", "true");
|
||||||
// Check that localStorage is updated
|
// Check that localStorage is updated
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
expect(localStorage.getItem("isMainNavCollapsed")).toBe("true");
|
||||||
});
|
});
|
||||||
@@ -188,6 +210,7 @@ describe("MainNavigation", () => {
|
|||||||
// Check state after second toggle (expanded)
|
// Check state after second toggle (expanded)
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Check that the attribute eventually becomes false
|
// Check that the attribute eventually becomes false
|
||||||
|
expect(projectSwitcher).toHaveAttribute("data-collapsed", "false");
|
||||||
// Check that localStorage is updated
|
// Check that localStorage is updated
|
||||||
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
expect(localStorage.getItem("isMainNavCollapsed")).toBe("false");
|
||||||
});
|
});
|
||||||
@@ -223,6 +246,8 @@ describe("MainNavigation", () => {
|
|||||||
expect(screen.getByText("common.account")).toBeInTheDocument();
|
expect(screen.getByText("common.account")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("common.organization")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("common.license")).toBeInTheDocument(); // Not cloud, not member
|
||||||
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
expect(screen.getByText("common.documentation")).toBeInTheDocument();
|
||||||
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
expect(screen.getByText("common.logout")).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -243,6 +268,46 @@ describe("MainNavigation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("handles organization switching", async () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||||
|
await userEvent.click(userTrigger);
|
||||||
|
|
||||||
|
// Wait for the initial dropdown items
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||||
|
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||||
|
|
||||||
|
const org2Item = await screen.findByText("Another Org"); // findByText includes waitFor
|
||||||
|
await userEvent.click(org2Item);
|
||||||
|
|
||||||
|
expect(mockRouterPush).toHaveBeenCalledWith("/organizations/org2/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opens create organization modal", async () => {
|
||||||
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
|
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||||
|
await userEvent.click(userTrigger);
|
||||||
|
|
||||||
|
// Wait for the initial dropdown items
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("common.switch_organization")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const switchOrgTrigger = screen.getByText("common.switch_organization").closest("div[role='menuitem']")!;
|
||||||
|
await userEvent.hover(switchOrgTrigger); // Hover to open sub-menu
|
||||||
|
|
||||||
|
const createOrgButton = await screen.findByText("common.create_new_organization"); // findByText includes waitFor
|
||||||
|
await userEvent.click(createOrgButton);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("create-org-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("hides new version banner for members or if no new version", async () => {
|
test("hides new version banner for members or if no new version", async () => {
|
||||||
// Test for member
|
// Test for member
|
||||||
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
vi.mocked(getLatestStableFbReleaseAction).mockResolvedValue({ data: "v1.1.0" });
|
||||||
@@ -270,25 +335,34 @@ describe("MainNavigation", () => {
|
|||||||
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("project-switcher")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("passes isAccessControlAllowed props to ProjectSwitcher", () => {
|
test("shows billing link and hides license link in cloud", async () => {
|
||||||
|
render(<MainNavigation {...defaultProps} isFormbricksCloud={true} />);
|
||||||
|
const userTrigger = screen.getByTestId("profile-avatar").parentElement!;
|
||||||
|
await userEvent.click(userTrigger);
|
||||||
|
|
||||||
|
// Wait for dropdown items
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("common.license")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes canDoRoleManagement props to ProjectSwitcher", () => {
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
// Test basic navigation structure is rendered (aside element with complementary role)
|
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("true");
|
||||||
expect(screen.getByTestId("profile-avatar")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles no organizationTeams", () => {
|
test("handles no organizationTeams", () => {
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} />);
|
||||||
|
|
||||||
// Test that navigation renders correctly with no teams
|
expect(screen.getByTestId("organization-teams-count")).toHaveTextContent("0");
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles isAccessControlAllowed false", () => {
|
test("handles canDoRoleManagement false", () => {
|
||||||
render(<MainNavigation {...defaultProps} />);
|
render(<MainNavigation {...defaultProps} canDoRoleManagement={false} />);
|
||||||
|
|
||||||
// Test that navigation renders correctly with access control disabled
|
expect(screen.getByTestId("can-do-role-management")).toHaveTextContent("false");
|
||||||
expect(screen.getByRole("complementary")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,26 @@
|
|||||||
|
|
||||||
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[environmentId]/actions/actions";
|
||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
import { isNewerVersion } from "@/app/(app)/environments/[environmentId]/lib/utils";
|
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
|
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
@@ -21,14 +30,18 @@ import {
|
|||||||
BlocksIcon,
|
BlocksIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
|
CreditCardIcon,
|
||||||
|
KeyIcon,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
|
PlusIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
UsersIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -37,40 +50,55 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
|
organizations: TOrganization[];
|
||||||
user: TUser;
|
user: TUser;
|
||||||
organization: TOrganization;
|
organization: TOrganization;
|
||||||
projects: { id: string; name: string }[];
|
projects: TProject[];
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
|
organizationProjectsLimit: number;
|
||||||
|
isLicenseActive: boolean;
|
||||||
|
canDoRoleManagement: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
|
organizations,
|
||||||
organization,
|
organization,
|
||||||
user,
|
user,
|
||||||
projects,
|
projects,
|
||||||
|
isMultiOrgEnabled,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
|
organizationProjectsLimit,
|
||||||
|
isLicenseActive,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
canDoRoleManagement,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
|
const [currentOrganizationName, setCurrentOrganizationName] = useState("");
|
||||||
|
const [currentOrganizationId, setCurrentOrganizationId] = useState("");
|
||||||
|
const [showCreateOrganizationModal, setShowCreateOrganizationModal] = useState(false);
|
||||||
const [isCollapsed, setIsCollapsed] = useState(true);
|
const [isCollapsed, setIsCollapsed] = useState(true);
|
||||||
const [isTextVisible, setIsTextVisible] = useState(true);
|
const [isTextVisible, setIsTextVisible] = useState(true);
|
||||||
const [latestVersion, setLatestVersion] = useState("");
|
const [latestVersion, setLatestVersion] = useState("");
|
||||||
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
|
||||||
|
|
||||||
const project = projects.find((project) => project.id === environment.projectId);
|
const project = projects.find((project) => project.id === environment.projectId);
|
||||||
const { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
const { isManager, isOwner, isMember, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
const isPricingDisabled = isMember;
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
@@ -91,11 +119,40 @@ export const MainNavigation = ({
|
|||||||
}, [isCollapsed]);
|
}, [isCollapsed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Auto collapse project navbar on org and account settings
|
if (organization && organization.name !== "") {
|
||||||
if (pathname?.includes("/settings")) {
|
setCurrentOrganizationName(organization.name);
|
||||||
setIsCollapsed(true);
|
setCurrentOrganizationId(organization.id);
|
||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [organization]);
|
||||||
|
|
||||||
|
const sortedOrganizations = useMemo(() => {
|
||||||
|
return [...organizations].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [organizations]);
|
||||||
|
|
||||||
|
const sortedProjects = useMemo(() => {
|
||||||
|
const channelOrder: (string | null)[] = ["website", "app", "link", null];
|
||||||
|
|
||||||
|
const groupedProjects = projects.reduce(
|
||||||
|
(acc, project) => {
|
||||||
|
const channel = project.config.channel;
|
||||||
|
const key = channel !== null ? channel : "null";
|
||||||
|
acc[key] = acc[key] || [];
|
||||||
|
acc[key].push(project);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof projects>
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.keys(groupedProjects).forEach((channel) => {
|
||||||
|
groupedProjects[channel].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
return channelOrder.flatMap((channel) => groupedProjects[channel !== null ? channel : "null"] || []);
|
||||||
|
}, [projects]);
|
||||||
|
|
||||||
|
const handleEnvironmentChangeByOrganization = (organizationId: string) => {
|
||||||
|
router.push(`/organizations/${organizationId}/`);
|
||||||
|
};
|
||||||
|
|
||||||
const mainNavigation = useMemo(
|
const mainNavigation = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -141,14 +198,25 @@ export const MainNavigation = ({
|
|||||||
icon: UserCircleIcon,
|
icon: UserCircleIcon,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("common.documentation"),
|
label: t("common.organization"),
|
||||||
href: "https://formbricks.com/docs",
|
href: `/environments/${environment.id}/settings/general`,
|
||||||
target: "_blank",
|
icon: UsersIcon,
|
||||||
icon: ArrowUpRightIcon,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: t("common.share_feedback"),
|
label: t("common.billing"),
|
||||||
href: "https://github.com/formbricks/formbricks/issues",
|
href: `/environments/${environment.id}/settings/billing`,
|
||||||
|
hidden: !isFormbricksCloud,
|
||||||
|
icon: CreditCardIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.license"),
|
||||||
|
href: `/environments/${environment.id}/settings/enterprise`,
|
||||||
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
|
icon: KeyIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.documentation"),
|
||||||
|
href: "https://formbricks.com/docs",
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
icon: ArrowUpRightIcon,
|
icon: ArrowUpRightIcon,
|
||||||
},
|
},
|
||||||
@@ -161,7 +229,7 @@ export const MainNavigation = ({
|
|||||||
const latestVersionTag = res.data;
|
const latestVersionTag = res.data;
|
||||||
const currentVersionTag = `v${packageJson.version}`;
|
const currentVersionTag = `v${packageJson.version}`;
|
||||||
|
|
||||||
if (isNewerVersion(currentVersionTag, latestVersionTag)) {
|
if (currentVersionTag !== latestVersionTag) {
|
||||||
setLatestVersion(latestVersionTag);
|
setLatestVersion(latestVersionTag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +245,8 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded",
|
||||||
|
environment.type === "development" ? `h-[calc(100vh-1.25rem)]` : "h-screen"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -243,6 +312,23 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Project Switch */}
|
||||||
|
{!isBilling && (
|
||||||
|
<ProjectSwitcher
|
||||||
|
environmentId={environment.id}
|
||||||
|
projects={sortedProjects}
|
||||||
|
project={project}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
|
isLicenseActive={isLicenseActive}
|
||||||
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
isTextVisible={isTextVisible}
|
||||||
|
organization={organization}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
canDoRoleManagement={canDoRoleManagement}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* User Switch */}
|
{/* User Switch */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -251,27 +337,29 @@ export const MainNavigation = ({
|
|||||||
id="userDropdownTrigger"
|
id="userDropdownTrigger"
|
||||||
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
className="w-full rounded-br-xl border-t py-4 transition-colors duration-200 hover:bg-slate-50 focus:outline-none">
|
||||||
<div
|
<div
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-row items-center gap-3",
|
"flex cursor-pointer flex-row items-center space-x-3",
|
||||||
isCollapsed ? "justify-center px-2" : "px-4"
|
isCollapsed ? "pl-2" : "pl-4"
|
||||||
)}>
|
)}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} imageUrl={user.imageUrl} />
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={cn(isTextVisible ? "opacity-0" : "opacity-100")}>
|
||||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className={cn(
|
||||||
"ph-no-capture ph-no-capture -mb-0.5 truncate text-sm font-bold text-slate-700"
|
"ph-no-capture ph-no-capture -mb-0.5 max-w-28 truncate text-sm font-bold text-slate-700"
|
||||||
)}>
|
)}>
|
||||||
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
{user?.name ? <span>{user?.name}</span> : <span>{user?.email}</span>}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-slate-700">{t("common.account")}</p>
|
<p
|
||||||
|
title={capitalizeFirstLetter(organization?.name)}
|
||||||
|
className="max-w-28 truncate text-sm text-slate-500">
|
||||||
|
{capitalizeFirstLetter(organization?.name)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon className={cn("h-5 w-5 text-slate-700 hover:text-slate-500")} />
|
||||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,20 +373,24 @@ export const MainNavigation = ({
|
|||||||
align="end">
|
align="end">
|
||||||
{/* Dropdown Items */}
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map(
|
||||||
<Link
|
(link) =>
|
||||||
href={link.href}
|
!link.hidden && (
|
||||||
target={link.target}
|
<Link
|
||||||
className="flex w-full items-center"
|
href={link.href}
|
||||||
key={link.label}
|
target={link.target}
|
||||||
rel={link.target === "_blank" ? "noopener noreferrer" : undefined}>
|
className="flex w-full items-center"
|
||||||
<DropdownMenuItem>
|
key={link.label}>
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
<DropdownMenuItem>
|
||||||
{link.label}
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
</DropdownMenuItem>
|
{link.label}
|
||||||
</Link>
|
</DropdownMenuItem>
|
||||||
))}
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const route = await signOutWithAudit({
|
const route = await signOutWithAudit({
|
||||||
@@ -314,12 +406,55 @@ export const MainNavigation = ({
|
|||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{/* Organization Switch */}
|
||||||
|
|
||||||
|
{(isMultiOrgEnabled || organizations.length > 1) && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger className="rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p>{currentOrganizationName}</p>
|
||||||
|
<p className="block text-xs text-slate-500">{t("common.switch_organization")}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent sideOffset={10} alignOffset={5}>
|
||||||
|
<DropdownMenuRadioGroup
|
||||||
|
value={currentOrganizationId}
|
||||||
|
onValueChange={(organizationId) =>
|
||||||
|
handleEnvironmentChangeByOrganization(organizationId)
|
||||||
|
}>
|
||||||
|
{sortedOrganizations.map((organization) => (
|
||||||
|
<DropdownMenuRadioItem
|
||||||
|
value={organization.id}
|
||||||
|
className="cursor-pointer rounded-lg"
|
||||||
|
key={organization.id}>
|
||||||
|
{organization.name}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setShowCreateOrganizationModal(true)}
|
||||||
|
icon={<PlusIcon className="mr-2 h-4 w-4" />}>
|
||||||
|
<span>{t("common.create_new_organization")}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
<CreateOrganizationModal
|
||||||
|
open={showCreateOrganizationModal}
|
||||||
|
setOpen={(val) => setShowCreateOrganizationModal(val)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-4
@@ -28,7 +28,7 @@ const TestComponent = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div data-testid="responseStatus">{selectedFilter.responseStatus}</div>
|
<div data-testid="onlyComplete">{selectedFilter.onlyComplete.toString()}</div>
|
||||||
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
<div data-testid="filterLength">{selectedFilter.filter.length}</div>
|
||||||
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
<div data-testid="questionOptionsLength">{selectedOptions.questionOptions.length}</div>
|
||||||
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
<div data-testid="questionFilterOptionsLength">{selectedOptions.questionFilterOptions.length}</div>
|
||||||
@@ -44,7 +44,7 @@ const TestComponent = () => {
|
|||||||
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
filterType: { filterValue: "value1", filterComboBoxValue: "option1" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
responseStatus: "complete",
|
onlyComplete: true,
|
||||||
})
|
})
|
||||||
}>
|
}>
|
||||||
Update Filter
|
Update Filter
|
||||||
@@ -81,7 +81,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
</ResponseFilterProvider>
|
</ResponseFilterProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("all");
|
expect(screen.getByTestId("onlyComplete").textContent).toBe("false");
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
expect(screen.getByTestId("filterLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionOptionsLength").textContent).toBe("0");
|
||||||
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
expect(screen.getByTestId("questionFilterOptionsLength").textContent).toBe("0");
|
||||||
@@ -99,7 +99,7 @@ describe("ResponseFilterContext", () => {
|
|||||||
const updateButton = screen.getByText("Update Filter");
|
const updateButton = screen.getByText("Update Filter");
|
||||||
await userEvent.click(updateButton);
|
await userEvent.click(updateButton);
|
||||||
|
|
||||||
expect(screen.getByTestId("responseStatus").textContent).toBe("complete");
|
expect(screen.getByTestId("onlyComplete").textContent).toBe("true");
|
||||||
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
expect(screen.getByTestId("filterLength").textContent).toBe("1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+3
-5
@@ -16,11 +16,9 @@ export interface FilterValue {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TResponseStatus = "all" | "complete" | "partial";
|
|
||||||
|
|
||||||
export interface SelectedFilterValue {
|
export interface SelectedFilterValue {
|
||||||
filter: FilterValue[];
|
filter: FilterValue[];
|
||||||
responseStatus: TResponseStatus;
|
onlyComplete: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelectedFilterOptions {
|
interface SelectedFilterOptions {
|
||||||
@@ -49,7 +47,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
// state holds the filter selected value
|
// state holds the filter selected value
|
||||||
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
const [selectedFilter, setSelectedFilter] = useState<SelectedFilterValue>({
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
});
|
});
|
||||||
// state holds all the options of the responses fetched
|
// state holds all the options of the responses fetched
|
||||||
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
const [selectedOptions, setSelectedOptions] = useState<SelectedFilterOptions>({
|
||||||
@@ -69,7 +67,7 @@ const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) =>
|
|||||||
});
|
});
|
||||||
setSelectedFilter({
|
setSelectedFilter({
|
||||||
filter: [],
|
filter: [],
|
||||||
responseStatus: "all",
|
onlyComplete: false,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
import { TopControlBar } from "./TopControlBar";
|
||||||
|
|
||||||
|
// Mock the child component
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/components/TopControlButtons", () => ({
|
||||||
|
TopControlButtons: vi.fn(() => <div data-testid="top-control-buttons">Mocked TopControlButtons</div>),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockEnvironment: TEnvironment = {
|
||||||
|
id: "env1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
type: "production",
|
||||||
|
projectId: "proj1",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvironments: TEnvironment[] = [
|
||||||
|
mockEnvironment,
|
||||||
|
{ ...mockEnvironment, id: "env2", type: "development" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockMembershipRole: TOrganizationRole = "owner";
|
||||||
|
const mockProjectPermission = "manage";
|
||||||
|
|
||||||
|
describe("TopControlBar", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly and passes props to TopControlButtons", () => {
|
||||||
|
render(
|
||||||
|
<TopControlBar
|
||||||
|
environment={mockEnvironment}
|
||||||
|
environments={mockEnvironments}
|
||||||
|
membershipRole={mockMembershipRole}
|
||||||
|
projectPermission={mockProjectPermission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the main div is rendered
|
||||||
|
const mainDiv = screen.getByTestId("top-control-buttons").parentElement?.parentElement?.parentElement;
|
||||||
|
expect(mainDiv).toHaveClass(
|
||||||
|
"fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the mocked child component is rendered
|
||||||
|
expect(screen.getByTestId("top-control-buttons")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check if the child component received the correct props
|
||||||
|
expect(TopControlButtons).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
environment: mockEnvironment,
|
||||||
|
environments: mockEnvironments,
|
||||||
|
membershipRole: mockMembershipRole,
|
||||||
|
projectPermission: mockProjectPermission,
|
||||||
|
},
|
||||||
|
undefined // Updated from {} to undefined
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,110 +1,32 @@
|
|||||||
"use client";
|
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||||
|
|
||||||
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
|
|
||||||
import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context";
|
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|
||||||
import { Button } from "@/modules/ui/components/button";
|
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
interface TopControlBarProps {
|
interface SideBarProps {
|
||||||
|
environment: TEnvironment;
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
currentOrganizationId: string;
|
|
||||||
organizations: { id: string; name: string }[];
|
|
||||||
currentProjectId: string;
|
|
||||||
projects: { id: string; name: string }[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
projectPermission: TTeamPermission | null;
|
projectPermission: TTeamPermission | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TopControlBar = ({
|
export const TopControlBar = ({
|
||||||
|
environment,
|
||||||
environments,
|
environments,
|
||||||
currentOrganizationId,
|
|
||||||
organizations,
|
|
||||||
currentProjectId,
|
|
||||||
projects,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
isOwnerOrManager,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
membershipRole,
|
membershipRole,
|
||||||
projectPermission,
|
projectPermission,
|
||||||
}: TopControlBarProps) => {
|
}: SideBarProps) => {
|
||||||
const { t } = useTranslate();
|
|
||||||
|
|
||||||
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
const { environment } = useEnvironment();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 top-0 z-30 flex h-14 w-full items-center justify-end bg-slate-50 px-6">
|
||||||
className="flex h-14 w-full items-center justify-between bg-slate-50 px-6"
|
<div className="shadow-xs z-10">
|
||||||
data-testid="fb__global-top-control-bar">
|
<div className="flex w-fit items-center space-x-2 py-2">
|
||||||
<div className="flex items-center">
|
<TopControlButtons
|
||||||
<ProjectAndOrgSwitch
|
environment={environment}
|
||||||
currentEnvironmentId={environment.id}
|
environments={environments}
|
||||||
environments={environments}
|
membershipRole={membershipRole}
|
||||||
currentOrganizationId={currentOrganizationId}
|
projectPermission={projectPermission}
|
||||||
organizations={organizations}
|
/>
|
||||||
currentProjectId={currentProjectId}
|
</div>
|
||||||
projects={projects}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isLicenseActive={isLicenseActive}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
isMember={isMember}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="z-50 flex items-center space-x-2">
|
|
||||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
|
||||||
<Link
|
|
||||||
href="https://github.com/formbricks/formbricks/issues"
|
|
||||||
target="_blank"
|
|
||||||
aria-label={t("common.share_feedback")}
|
|
||||||
rel="noopener noreferrer">
|
|
||||||
<BugIcon />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
|
|
||||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
|
||||||
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
|
||||||
<Link href={`/environments/${environment.id}/settings/profile`} aria-label={t("common.account")}>
|
|
||||||
<CircleUserIcon />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
{isBilling || isReadOnly ? (
|
|
||||||
<></>
|
|
||||||
) : (
|
|
||||||
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
|
||||||
<Button variant="secondary" size="icon" className="h-fit w-fit p-1" asChild>
|
|
||||||
<Link
|
|
||||||
href={`/environments/${environment.id}/surveys/templates`}
|
|
||||||
aria-label={t("common.new_survey")}>
|
|
||||||
<PlusIcon />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
import { TopControlButtons } from "./TopControlButtons";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
const mockPush = vi.fn();
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: vi.fn(() => ({ push: mockPush })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/membership/utils", () => ({
|
||||||
|
getAccessFlags: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ee/teams/utils/teams", () => ({
|
||||||
|
getTeamPermissionFlags: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch", () => ({
|
||||||
|
EnvironmentSwitch: vi.fn(() => <div data-testid="environment-switch">EnvironmentSwitch</div>),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/button", () => ({
|
||||||
|
Button: ({ children, onClick, variant, size, className, asChild, ...props }: any) => {
|
||||||
|
const Tag = asChild ? "div" : "button"; // Use div if asChild is true for Link mock
|
||||||
|
return (
|
||||||
|
<Tag onClick={onClick} data-testid={`button-${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||||
|
TooltipRenderer: ({ children, tooltipContent }: { children: React.ReactNode; tooltipContent: string }) => (
|
||||||
|
<div data-testid={`tooltip-${tooltipContent.split(".").pop()}`}>{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("lucide-react", () => ({
|
||||||
|
BugIcon: () => <div data-testid="bug-icon" />,
|
||||||
|
CircleUserIcon: () => <div data-testid="circle-user-icon" />,
|
||||||
|
PlusIcon: () => <div data-testid="plus-icon" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/link", () => ({
|
||||||
|
default: ({ children, href, target }: { children: React.ReactNode; href: string; target?: string }) => (
|
||||||
|
<a href={href} target={target} data-testid="link-mock">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock data
|
||||||
|
const mockEnvironmentDev: TEnvironment = {
|
||||||
|
id: "dev-env-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
type: "development",
|
||||||
|
projectId: "project-id",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvironmentProd: TEnvironment = {
|
||||||
|
id: "prod-env-id",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
type: "production",
|
||||||
|
projectId: "project-id",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEnvironments = [mockEnvironmentDev, mockEnvironmentProd];
|
||||||
|
|
||||||
|
describe("TopControlButtons", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Default mocks for access flags
|
||||||
|
vi.mocked(getAccessFlags).mockReturnValue({
|
||||||
|
isOwner: false,
|
||||||
|
isMember: false,
|
||||||
|
isBilling: false,
|
||||||
|
} as any);
|
||||||
|
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||||
|
hasReadAccess: false,
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (
|
||||||
|
membershipRole?: TOrganizationRole,
|
||||||
|
projectPermission: any = null,
|
||||||
|
isBilling = false,
|
||||||
|
hasReadAccess = false
|
||||||
|
) => {
|
||||||
|
vi.mocked(getAccessFlags).mockReturnValue({
|
||||||
|
isMember: membershipRole === "member",
|
||||||
|
isBilling: isBilling,
|
||||||
|
isOwner: membershipRole === "owner",
|
||||||
|
} as any);
|
||||||
|
vi.mocked(getTeamPermissionFlags).mockReturnValue({
|
||||||
|
hasReadAccess: hasReadAccess,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<TopControlButtons
|
||||||
|
environment={mockEnvironmentDev}
|
||||||
|
environments={mockEnvironments}
|
||||||
|
membershipRole={membershipRole}
|
||||||
|
projectPermission={projectPermission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders correctly for Owner role", async () => {
|
||||||
|
renderComponent("owner");
|
||||||
|
|
||||||
|
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("bug-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("circle-user-icon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check link
|
||||||
|
const link = screen.getByTestId("link-mock");
|
||||||
|
expect(link).toHaveAttribute("href", "https://github.com/formbricks/formbricks/issues");
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
|
||||||
|
// Click account button
|
||||||
|
const accountButton = screen.getByTestId("circle-user-icon").closest("button");
|
||||||
|
await userEvent.click(accountButton!);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/settings/profile`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click new survey button
|
||||||
|
const newSurveyButton = screen.getByTestId("plus-icon").closest("button");
|
||||||
|
await userEvent.click(newSurveyButton!);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockPush).toHaveBeenCalledWith(`/environments/${mockEnvironmentDev.id}/surveys/templates`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides EnvironmentSwitch for Billing role", () => {
|
||||||
|
renderComponent(undefined, null, true); // isBilling = true
|
||||||
|
expect(screen.queryByTestId("environment-switch")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument(); // Hidden for billing
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides New Survey button for Billing role", () => {
|
||||||
|
renderComponent(undefined, null, true); // isBilling = true
|
||||||
|
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides New Survey button for read-only Member", () => {
|
||||||
|
renderComponent("member", null, false, true); // isMember = true, hasReadAccess = true
|
||||||
|
expect(screen.getByTestId("environment-switch")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-share_feedback")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("tooltip-account")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("tooltip-new_survey")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("plus-icon")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows New Survey button for Member with write access", () => {
|
||||||
|
renderComponent("member", null, false, false); // isMember = true, hasReadAccess = false
|
||||||
|
expect(screen.getByTestId("tooltip-new_survey")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
|
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
|
interface TopControlButtonsProps {
|
||||||
|
environment: TEnvironment;
|
||||||
|
environments: TEnvironment[];
|
||||||
|
membershipRole?: TOrganizationRole;
|
||||||
|
projectPermission: TTeamPermission | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TopControlButtons = ({
|
||||||
|
environment,
|
||||||
|
environments,
|
||||||
|
membershipRole,
|
||||||
|
projectPermission,
|
||||||
|
}: TopControlButtonsProps) => {
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
||||||
|
const isReadOnly = isMember && hasReadAccess;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="z-50 flex items-center space-x-2">
|
||||||
|
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||||
|
|
||||||
|
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||||
|
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||||
|
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||||
|
<BugIcon />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipRenderer>
|
||||||
|
|
||||||
|
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-fit w-fit bg-slate-50 p-1"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/environments/${environment.id}/settings/profile`);
|
||||||
|
}}>
|
||||||
|
<CircleUserIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipRenderer>
|
||||||
|
{isBilling || isReadOnly ? (
|
||||||
|
<></>
|
||||||
|
) : (
|
||||||
|
<TooltipRenderer tooltipContent={t("common.new_survey")}>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="h-fit w-fit p-1"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/environments/${environment.id}/surveys/templates`);
|
||||||
|
}}>
|
||||||
|
<PlusIcon />
|
||||||
|
</Button>
|
||||||
|
</TooltipRenderer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
-349
@@ -1,349 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { EnvironmentBreadcrumb } from "./environment-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, isHighlighted, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} data-highlighted={isHighlighted} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
|
||||||
TooltipProvider: ({ children }: any) => <div data-testid="tooltip-provider">{children}</div>,
|
|
||||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
|
||||||
TooltipTrigger: ({ children, asChild }: any) => (
|
|
||||||
<div data-testid="tooltip-trigger" data-as-child={asChild}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
TooltipContent: ({ children, className }: any) => (
|
|
||||||
<div data-testid="tooltip-content" className={className}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
Code2Icon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "code2-header-icon" : "code2-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>Code2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
CircleHelpIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="circle-help-icon" className={className}>
|
|
||||||
<title>CircleHelp Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("EnvironmentBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProductionEnvironment: TEnvironment = {
|
|
||||||
id: "env-prod-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "production",
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockDevelopmentEnvironment: TEnvironment = {
|
|
||||||
id: "env-dev-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "development",
|
|
||||||
projectId: "project-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironments: TEnvironment[] = [mockProductionEnvironment, mockDevelopmentEnvironment];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders environment breadcrumb with production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("code2-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders environment breadcrumb with development environment and shows tooltip", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getAllByText("development")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
expect(screen.getByTestId("tooltip-provider")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("circle-help-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("highlights breadcrumb item for development environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not highlight breadcrumb item for production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-highlighted", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down icon when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByTestId("chevron-down-icon")).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders dropdown content with environment options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.choose_environment")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all environment options in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
expect(checkboxItems).toHaveLength(2);
|
|
||||||
|
|
||||||
// Check production environment option
|
|
||||||
const productionOption = checkboxItems.find((item) => item.textContent?.includes("production"));
|
|
||||||
expect(productionOption).toBeInTheDocument();
|
|
||||||
expect(productionOption).toHaveAttribute("data-checked", "true");
|
|
||||||
|
|
||||||
// Check development environment option
|
|
||||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
|
||||||
expect(developmentOption).toBeInTheDocument();
|
|
||||||
expect(developmentOption).toHaveAttribute("data-checked", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles environment change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const developmentOption = checkboxItems.find((item) => item.textContent?.includes("development"));
|
|
||||||
|
|
||||||
expect(developmentOption).toBeInTheDocument();
|
|
||||||
await user.click(developmentOption!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/environments/env-dev-1/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("capitalizes environment type in display", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const environmentSpans = screen.getAllByText("production");
|
|
||||||
const triggerSpan = environmentSpans.find((span) => span.className.includes("capitalize"));
|
|
||||||
expect(triggerSpan).toHaveClass("capitalize");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("tooltip shows correct content for development environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockDevelopmentEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const tooltipContent = screen.getByTestId("tooltip-content");
|
|
||||||
expect(tooltipContent).toHaveClass("text-white bg-red-800 border-none mt-2");
|
|
||||||
expect(tooltipContent).toHaveTextContent("common.development_environment_banner");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders without tooltip for production environment", () => {
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("circle-help-icon")).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("tooltip-provider")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={mockEnvironments}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single environment scenario", () => {
|
|
||||||
const singleEnvironment = [mockProductionEnvironment];
|
|
||||||
|
|
||||||
render(
|
|
||||||
<EnvironmentBreadcrumb
|
|
||||||
environments={singleEnvironment}
|
|
||||||
currentEnvironmentId={mockProductionEnvironment.id}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("production")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
export const EnvironmentBreadcrumb = ({
|
|
||||||
environments,
|
|
||||||
currentEnvironmentId,
|
|
||||||
}: {
|
|
||||||
environments: { id: string; type: string }[];
|
|
||||||
currentEnvironmentId: string;
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
|
||||||
|
|
||||||
if (!currentEnvironment) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleEnvironmentChange = (environmentId: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
router.push(`/environments/${environmentId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const developmentTooltip = () => {
|
|
||||||
return (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<CircleHelpIcon className="h-3 w-3" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
|
|
||||||
{t("common.development_environment_banner")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbItem
|
|
||||||
isActive={isEnvironmentDropdownOpen}
|
|
||||||
isHighlighted={currentEnvironment.type === "development"}>
|
|
||||||
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
|
||||||
id="environmentDropdownTrigger"
|
|
||||||
asChild>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
<span className="capitalize">{currentEnvironment.type}</span>
|
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
|
||||||
{currentEnvironment.type === "development" && developmentTooltip()}
|
|
||||||
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="mt-2" align="start">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<Code2Icon className="mr-2 inline h-4 w-4" />
|
|
||||||
{t("common.choose_environment")}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{environments.map((env) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={env.id}
|
|
||||||
checked={env.id === currentEnvironment.id}
|
|
||||||
onClick={() => handleEnvironmentChange(env.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2 capitalize">
|
|
||||||
<span>{env.type}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-560
@@ -1,560 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { OrganizationBreadcrumb } from "./organization-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
usePathname: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/organization/components/CreateOrganizationModal", () => ({
|
|
||||||
CreateOrganizationModal: ({ open, setOpen }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="create-organization-modal">
|
|
||||||
<button type="button" onClick={() => setOpen(false)}>
|
|
||||||
Close Modal
|
|
||||||
</button>
|
|
||||||
Create Organization Modal
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
DropdownMenuSeparator: () => <div data-testid="dropdown-separator" />,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
BuildingIcon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "building-header-icon" : "building-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>Building Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronRight Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
PlusIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="plus-icon" className={className}>
|
|
||||||
<title>Plus Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
SettingsIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="settings-icon" className={className}>
|
|
||||||
<title>Settings Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("OrganizationBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization1: TOrganization = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization 1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization2: TOrganization = {
|
|
||||||
id: "org-2",
|
|
||||||
name: "Test Organization 2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "startup",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganizations = [mockOrganization1, mockOrganization2];
|
|
||||||
const currentEnvironmentId = "env-123";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/");
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Single Organization Setup", () => {
|
|
||||||
test("renders organization breadcrumb without dropdown for single org", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Test Organization 1")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows organization settings without organization switcher", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText("common.choose_organization")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Multi Organization Setup", () => {
|
|
||||||
test("renders organization breadcrumb with dropdown for multi org", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("building-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Organization 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron icons correctly", () => {
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should show chevron right when closed
|
|
||||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders organization selector in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
expect(checkboxItems.length).toBeGreaterThanOrEqual(2); // Organizations + create new option + settings
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles organization change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const org2Option = checkboxItems.find((item) => item.textContent?.includes("Test Organization 2"));
|
|
||||||
|
|
||||||
expect(org2Option).toBeInTheDocument();
|
|
||||||
await user.click(org2Option!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/organizations/org-2/");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows create new organization option when multi org is enabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
expect(createOrgOption).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens create organization modal when clicking create new option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
await user.click(createOrgOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides create new organization option when multi org is disabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.queryByText("common.create_new_organization")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Organization Settings", () => {
|
|
||||||
test("renders all organization settings options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.organization_settings")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.general")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.teams")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.api_keys")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.billing")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles navigation to organization settings", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const generalOption = screen.getByText("common.general");
|
|
||||||
await user.click(generalOption);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith(`/environments/${currentEnvironmentId}/settings/general`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("marks current settings page as checked", async () => {
|
|
||||||
vi.mocked(usePathname).mockReturnValue("/environments/env-123/settings/teams");
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const teamsOption = checkboxItems.find((item) => item.textContent?.includes("common.teams"));
|
|
||||||
|
|
||||||
expect(teamsOption).toBeInTheDocument();
|
|
||||||
expect(teamsOption).toHaveAttribute("data-checked", "true");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles single organization with multi org enabled", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should still show organization selector since multi org is enabled
|
|
||||||
expect(screen.getByText("common.choose_organization")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.create_new_organization")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows separator between organization switcher and settings", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-separator")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes create organization modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={mockOrganization1.id}
|
|
||||||
organizations={mockOrganizations}
|
|
||||||
isMultiOrgEnabled={true}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={true}
|
|
||||||
isMember={false}
|
|
||||||
isOwnerOrManager={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const createOrgOption = screen.getByText("common.create_new_organization");
|
|
||||||
await user.click(createOrgOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-organization-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("create-organization-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-173
@@ -1,173 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import {
|
|
||||||
BuildingIcon,
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
Loader2,
|
|
||||||
PlusIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
|
||||||
currentOrganizationId: string;
|
|
||||||
organizations: { id: string; name: string }[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
currentEnvironmentId?: string;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isMember: boolean;
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OrganizationBreadcrumb = ({
|
|
||||||
currentOrganizationId,
|
|
||||||
organizations,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
currentEnvironmentId,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isMember,
|
|
||||||
isOwnerOrManager,
|
|
||||||
}: OrganizationBreadcrumbProps) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
|
||||||
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const currentOrganization = organizations.find((org) => org.id === currentOrganizationId);
|
|
||||||
|
|
||||||
if (!currentOrganization) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOrganizationChange = (organizationId: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
router.push(`/organizations/${organizationId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hide organization dropdown for single org setups (on-premise)
|
|
||||||
const showOrganizationDropdown = isMultiOrgEnabled || organizations.length > 1;
|
|
||||||
|
|
||||||
const organizationSettings = [
|
|
||||||
{
|
|
||||||
id: "general",
|
|
||||||
label: t("common.general"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/general`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.teams"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
label: t("common.api_keys"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
|
||||||
hidden: !isOwnerOrManager,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "billing",
|
|
||||||
label: t("common.billing"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
|
||||||
hidden: !isFormbricksCloud,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "enterprise",
|
|
||||||
label: t("common.enterprise_license"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
|
||||||
hidden: isFormbricksCloud || isMember,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BreadcrumbItem isActive={isOrganizationDropdownOpen}>
|
|
||||||
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
|
||||||
id="organizationDropdownTrigger"
|
|
||||||
asChild>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
<span>{currentOrganization.name}</span>
|
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
|
||||||
{isOrganizationDropdownOpen ? (
|
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
|
||||||
{showOrganizationDropdown && (
|
|
||||||
<>
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
|
||||||
{t("common.choose_organization")}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{organizations.map((org) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={org.id}
|
|
||||||
checked={org.id === currentOrganization.id}
|
|
||||||
onClick={() => handleOrganizationChange(org.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{org.name}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isMultiOrgEnabled && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={() => setOpenCreateOrganizationModal(true)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<span>{t("common.create_new_organization")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{currentEnvironmentId && (
|
|
||||||
<div>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<SettingsIcon className="mr-2 inline h-4 w-4" />
|
|
||||||
{t("common.organization_settings")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{organizationSettings.map((setting) => {
|
|
||||||
return setting.hidden ? null : (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={setting.id}
|
|
||||||
checked={pathname.includes(setting.id)}
|
|
||||||
hidden={setting.hidden}
|
|
||||||
onClick={() => router.push(setting.href)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
{setting.label}
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{openCreateOrganizationModal && (
|
|
||||||
<CreateOrganizationModal
|
|
||||||
open={openCreateOrganizationModal}
|
|
||||||
setOpen={setOpenCreateOrganizationModal}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-351
@@ -1,351 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
|
||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
|
||||||
import { ProjectAndOrgSwitch } from "./project-and-org-switch";
|
|
||||||
|
|
||||||
// Mock the individual breadcrumb components
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/organization-breadcrumb", () => ({
|
|
||||||
OrganizationBreadcrumb: ({
|
|
||||||
currentOrganizationId,
|
|
||||||
organizations,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
currentEnvironmentId,
|
|
||||||
}: any) => {
|
|
||||||
const currentOrganization = organizations.find((org: any) => org.id === currentOrganizationId);
|
|
||||||
return (
|
|
||||||
<div data-testid="organization-breadcrumb">
|
|
||||||
<div>Organization: {currentOrganization?.name}</div>
|
|
||||||
<div>Organizations Count: {organizations.length}</div>
|
|
||||||
<div>Multi Org: {isMultiOrgEnabled ? "Enabled" : "Disabled"}</div>
|
|
||||||
<div>Environment ID: {currentEnvironmentId}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/project-breadcrumb", () => ({
|
|
||||||
ProjectBreadcrumb: ({
|
|
||||||
currentProjectId,
|
|
||||||
projects,
|
|
||||||
isOwnerOrManager,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
currentOrganizationId,
|
|
||||||
currentEnvironmentId,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: any) => {
|
|
||||||
const currentProject = projects.find((project: any) => project.id === currentProjectId);
|
|
||||||
return (
|
|
||||||
<div data-testid="project-breadcrumb">
|
|
||||||
<div>Project: {currentProject?.name}</div>
|
|
||||||
<div>Projects Count: {projects.length}</div>
|
|
||||||
<div>Owner/Manager: {isOwnerOrManager ? "Yes" : "No"}</div>
|
|
||||||
<div>Project Limit: {organizationProjectsLimit}</div>
|
|
||||||
<div>Formbricks Cloud: {isFormbricksCloud ? "Yes" : "No"}</div>
|
|
||||||
<div>License Active: {isLicenseActive ? "Yes" : "No"}</div>
|
|
||||||
<div>Organization ID: {currentOrganizationId}</div>
|
|
||||||
<div>Environment ID: {currentEnvironmentId}</div>
|
|
||||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/app/(app)/environments/[environmentId]/components/environment-breadcrumb", () => ({
|
|
||||||
EnvironmentBreadcrumb: ({ environments, currentEnvironmentId }: any) => {
|
|
||||||
const currentEnvironment = environments.find((env: any) => env.id === currentEnvironmentId);
|
|
||||||
return (
|
|
||||||
<div data-testid="environment-breadcrumb">
|
|
||||||
<div>Environment: {currentEnvironment?.type}</div>
|
|
||||||
<div>Environments Count: {environments.length}</div>
|
|
||||||
<div>Environment ID: {currentEnvironment?.id}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
Breadcrumb: ({ children }: any) => (
|
|
||||||
<nav data-testid="breadcrumb" aria-label="breadcrumb">
|
|
||||||
{children}
|
|
||||||
</nav>
|
|
||||||
),
|
|
||||||
BreadcrumbList: ({ children, className }: any) => (
|
|
||||||
<ol data-testid="breadcrumb-list" className={className}>
|
|
||||||
{children}
|
|
||||||
</ol>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectAndOrgSwitch", () => {
|
|
||||||
const mockOrganization1 = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization 1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockOrganization2 = {
|
|
||||||
id: "org-2",
|
|
||||||
name: "Test Organization 2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject1 = {
|
|
||||||
id: "proj-1",
|
|
||||||
name: "Test Project 1",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject2 = {
|
|
||||||
id: "proj-2",
|
|
||||||
name: "Test Project 2",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment1: TEnvironment = {
|
|
||||||
id: "env-1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "production",
|
|
||||||
projectId: "proj-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockEnvironment2: TEnvironment = {
|
|
||||||
id: "env-2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
type: "development",
|
|
||||||
projectId: "proj-1",
|
|
||||||
appSetupCompleted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
organizations: [mockOrganization1, mockOrganization2],
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
projects: [mockProject1, mockProject2],
|
|
||||||
currentEnvironmentId: "env-1",
|
|
||||||
environments: [mockEnvironment1, mockEnvironment2],
|
|
||||||
isMultiOrgEnabled: true,
|
|
||||||
organizationProjectsLimit: 5,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
isLicenseActive: false,
|
|
||||||
isOwnerOrManager: true,
|
|
||||||
isAccessControlAllowed: true,
|
|
||||||
isMember: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Basic Rendering", () => {
|
|
||||||
test("renders main breadcrumb structure", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("breadcrumb-list")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toHaveAttribute("aria-label", "breadcrumb");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("applies correct CSS classes to breadcrumb list", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
|
||||||
expect(breadcrumbList).toHaveClass("gap-0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all three breadcrumb components", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("organization-breadcrumb")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("project-breadcrumb")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("environment-breadcrumb")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Organization Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to organization breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 1");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 2");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Enabled");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single organization setup", () => {
|
|
||||||
render(
|
|
||||||
<ProjectAndOrgSwitch
|
|
||||||
{...defaultProps}
|
|
||||||
organizations={[mockOrganization1]}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organizations Count: 1");
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to project breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project: Test Project 1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Projects Count: 2");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: Yes");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 5");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: Yes");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Allowed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles non-owner/manager user", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isOwnerOrManager={false} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles self-hosted setup", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isFormbricksCloud={false} isLicenseActive={true} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: Yes");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles access control restrictions", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} isAccessControlAllowed={false} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Environment Breadcrumb Integration", () => {
|
|
||||||
test("passes correct props to environment breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environment: production");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 2");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles development environment", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} currentEnvironmentId="env-2" />);
|
|
||||||
|
|
||||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environment: development");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environment ID: env-2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles single environment", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} environments={[mockEnvironment1]} />);
|
|
||||||
|
|
||||||
const envBreadcrumb = screen.getByTestId("environment-breadcrumb");
|
|
||||||
expect(envBreadcrumb).toHaveTextContent("Environments Count: 1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Props Propagation", () => {
|
|
||||||
test("correctly propagates organization limits", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={10} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 10");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("correctly propagates current organization to project breadcrumb", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} currentOrganizationId="org-2" />);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Organization: Test Organization 2");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Organization ID: org-2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles zero project limit", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} organizationProjectsLimit={0} />);
|
|
||||||
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Project Limit: 0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles all boolean props as false", () => {
|
|
||||||
render(
|
|
||||||
<ProjectAndOrgSwitch
|
|
||||||
{...defaultProps}
|
|
||||||
isMultiOrgEnabled={false}
|
|
||||||
isFormbricksCloud={false}
|
|
||||||
isLicenseActive={false}
|
|
||||||
isOwnerOrManager={false}
|
|
||||||
isAccessControlAllowed={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const orgBreadcrumb = screen.getByTestId("organization-breadcrumb");
|
|
||||||
const projectBreadcrumb = screen.getByTestId("project-breadcrumb");
|
|
||||||
|
|
||||||
expect(orgBreadcrumb).toHaveTextContent("Multi Org: Disabled");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Owner/Manager: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Formbricks Cloud: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("License Active: No");
|
|
||||||
expect(projectBreadcrumb).toHaveTextContent("Access Control: Not Allowed");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("maintains component order in DOM", () => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
|
|
||||||
const breadcrumbList = screen.getByTestId("breadcrumb-list");
|
|
||||||
const children = Array.from(breadcrumbList.children);
|
|
||||||
|
|
||||||
expect(children[0]).toHaveAttribute("data-testid", "organization-breadcrumb");
|
|
||||||
expect(children[1]).toHaveAttribute("data-testid", "project-breadcrumb");
|
|
||||||
expect(children[2]).toHaveAttribute("data-testid", "environment-breadcrumb");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("TypeScript Props Interface", () => {
|
|
||||||
test("accepts all required props without error", () => {
|
|
||||||
// This test ensures the component accepts the full interface
|
|
||||||
expect(() => {
|
|
||||||
render(<ProjectAndOrgSwitch {...defaultProps} />);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("works with minimal valid props", () => {
|
|
||||||
const minimalProps = {
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
organizations: [mockOrganization1],
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
projects: [mockProject1],
|
|
||||||
currentEnvironmentId: "env-1",
|
|
||||||
environments: [mockEnvironment1],
|
|
||||||
isMultiOrgEnabled: false,
|
|
||||||
organizationProjectsLimit: 1,
|
|
||||||
isFormbricksCloud: false,
|
|
||||||
isLicenseActive: false,
|
|
||||||
isOwnerOrManager: false,
|
|
||||||
isAccessControlAllowed: false,
|
|
||||||
isMember: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
render(<ProjectAndOrgSwitch {...minimalProps} />);
|
|
||||||
}).not.toThrow();
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
|
|
||||||
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
|
|
||||||
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
|
|
||||||
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
interface ProjectAndOrgSwitchProps {
|
|
||||||
currentOrganizationId: string;
|
|
||||||
organizations: { id: string; name: string }[];
|
|
||||||
currentProjectId?: string;
|
|
||||||
projects: { id: string; name: string }[];
|
|
||||||
currentEnvironmentId?: string;
|
|
||||||
environments: { id: string; type: string }[];
|
|
||||||
isMultiOrgEnabled: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
isMember: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectAndOrgSwitch = ({
|
|
||||||
currentOrganizationId,
|
|
||||||
organizations,
|
|
||||||
currentProjectId,
|
|
||||||
projects,
|
|
||||||
currentEnvironmentId,
|
|
||||||
environments,
|
|
||||||
isMultiOrgEnabled,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
isOwnerOrManager,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
isMember,
|
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
|
||||||
const sortedProjects = useMemo(() => projects.toSorted((a, b) => a.name.localeCompare(b.name)), [projects]);
|
|
||||||
const sortedOrganizations = useMemo(
|
|
||||||
() => organizations.toSorted((a, b) => a.name.localeCompare(b.name)),
|
|
||||||
[organizations]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList className="gap-0">
|
|
||||||
<OrganizationBreadcrumb
|
|
||||||
currentOrganizationId={currentOrganizationId}
|
|
||||||
organizations={sortedOrganizations}
|
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isMember={isMember}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
/>
|
|
||||||
{currentProjectId && currentEnvironmentId && (
|
|
||||||
<ProjectBreadcrumb
|
|
||||||
currentProjectId={currentProjectId}
|
|
||||||
currentOrganizationId={currentOrganizationId}
|
|
||||||
currentEnvironmentId={currentEnvironmentId}
|
|
||||||
projects={sortedProjects}
|
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
|
||||||
organizationProjectsLimit={organizationProjectsLimit}
|
|
||||||
isFormbricksCloud={isFormbricksCloud}
|
|
||||||
isLicenseActive={isLicenseActive}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{currentEnvironmentId && (
|
|
||||||
<EnvironmentBreadcrumb environments={environments} currentEnvironmentId={currentEnvironmentId} />
|
|
||||||
)}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
-497
@@ -1,497 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
import { TOrganization, TOrganizationBilling } from "@formbricks/types/organizations";
|
|
||||||
import { TProject } from "@formbricks/types/project";
|
|
||||||
import { ProjectBreadcrumb } from "./project-breadcrumb";
|
|
||||||
|
|
||||||
// Mock the dependencies
|
|
||||||
vi.mock("next/navigation", () => ({
|
|
||||||
useRouter: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tolgee/react", () => ({
|
|
||||||
useTranslate: () => ({
|
|
||||||
t: (key: string) => key,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/projects/components/project-limit-modal", () => ({
|
|
||||||
ProjectLimitModal: ({ open, setOpen, buttons, projectLimit }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="project-limit-modal">
|
|
||||||
<div>Project Limit: {projectLimit}</div>
|
|
||||||
<button onClick={() => setOpen(false)}>Close Limit Modal</button>
|
|
||||||
{buttons.map((button: any) => (
|
|
||||||
<button key={button.text} type="button" onClick={() => button.href && window.open(button.href)}>
|
|
||||||
{button.text}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/projects/components/create-project-modal", () => ({
|
|
||||||
CreateProjectModal: ({ open, setOpen, organizationId, isAccessControlAllowed }: any) =>
|
|
||||||
open ? (
|
|
||||||
<div data-testid="create-project-modal">
|
|
||||||
<div>Organization: {organizationId}</div>
|
|
||||||
<div>Access Control: {isAccessControlAllowed ? "Allowed" : "Not Allowed"}</div>
|
|
||||||
<button onClick={() => setOpen(false)}>Close Create Modal</button>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the UI components
|
|
||||||
vi.mock("@/modules/ui/components/breadcrumb", () => ({
|
|
||||||
BreadcrumbItem: ({ children, isActive, ...props }: any) => (
|
|
||||||
<li data-testid="breadcrumb-item" data-active={isActive} {...props}>
|
|
||||||
{children}
|
|
||||||
</li>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
|
|
||||||
DropdownMenu: ({ children, onOpenChange }: any) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="dropdown-menu"
|
|
||||||
onClick={() => onOpenChange?.(true)}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onOpenChange?.(true)}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuContent: ({ children, ...props }: any) => (
|
|
||||||
<div data-testid="dropdown-content" {...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuCheckboxItem: ({ children, onClick, checked, ...props }: any) => (
|
|
||||||
<div
|
|
||||||
data-testid="dropdown-checkbox-item"
|
|
||||||
data-checked={checked}
|
|
||||||
onClick={onClick}
|
|
||||||
onKeyDown={(e: any) => e.key === "Enter" && onClick?.()}
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
aria-checked={checked}
|
|
||||||
tabIndex={0}
|
|
||||||
{...props}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
DropdownMenuTrigger: ({ children, ...props }: any) => (
|
|
||||||
<button data-testid="dropdown-trigger" {...props}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
),
|
|
||||||
DropdownMenuGroup: ({ children }: any) => <div data-testid="dropdown-group">{children}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Lucide React icons
|
|
||||||
vi.mock("lucide-react", () => ({
|
|
||||||
FolderOpenIcon: ({ className, strokeWidth }: any) => {
|
|
||||||
const isHeader = className?.includes("mr-2");
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
data-testid={isHeader ? "folder-open-header-icon" : "folder-open-icon"}
|
|
||||||
className={className}
|
|
||||||
strokeWidth={strokeWidth}>
|
|
||||||
<title>FolderOpen Icon</title>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
ChevronDownIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-down-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronDown Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
ChevronRightIcon: ({ className, strokeWidth }: any) => (
|
|
||||||
<svg data-testid="chevron-right-icon" className={className} strokeWidth={strokeWidth}>
|
|
||||||
<title>ChevronRight Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
PlusIcon: ({ className }: any) => (
|
|
||||||
<svg data-testid="plus-icon" className={className}>
|
|
||||||
<title>Plus Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
Loader2: ({ className }: any) => (
|
|
||||||
<svg data-testid="loader-2-icon" className={className}>
|
|
||||||
<title>Loader2 Icon</title>
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("ProjectBreadcrumb", () => {
|
|
||||||
const mockPush = vi.fn();
|
|
||||||
const mockRouter = {
|
|
||||||
push: mockPush,
|
|
||||||
replace: vi.fn(),
|
|
||||||
refresh: vi.fn(),
|
|
||||||
back: vi.fn(),
|
|
||||||
forward: vi.fn(),
|
|
||||||
prefetch: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProject1 = {
|
|
||||||
id: "proj-1",
|
|
||||||
name: "Test Project 1",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
organizationId: "org-1",
|
|
||||||
languages: [],
|
|
||||||
} as unknown as TProject;
|
|
||||||
|
|
||||||
const mockProject2 = {
|
|
||||||
id: "proj-2",
|
|
||||||
name: "Test Project 2",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
organizationId: "org-1",
|
|
||||||
languages: [],
|
|
||||||
} as unknown as TProject;
|
|
||||||
|
|
||||||
const mockProjects = [mockProject1, mockProject2];
|
|
||||||
|
|
||||||
const mockOrganization: TOrganization = {
|
|
||||||
id: "org-1",
|
|
||||||
name: "Test Organization",
|
|
||||||
createdAt: new Date("2023-01-01"),
|
|
||||||
updatedAt: new Date("2023-01-01"),
|
|
||||||
billing: {
|
|
||||||
plan: "free",
|
|
||||||
stripeCustomerId: null,
|
|
||||||
} as unknown as TOrganizationBilling,
|
|
||||||
isAIEnabled: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultProps = {
|
|
||||||
currentProjectId: "proj-1",
|
|
||||||
currentOrganizationId: "org-1",
|
|
||||||
projects: mockProjects,
|
|
||||||
isOwnerOrManager: true,
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
isLicenseActive: false,
|
|
||||||
currentEnvironmentId: "env-123",
|
|
||||||
isAccessControlAllowed: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(useRouter).mockReturnValue(mockRouter as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Basic Rendering", () => {
|
|
||||||
test("renders project breadcrumb correctly", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-trigger")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("folder-open-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron icons correctly", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
// Should show chevron right when closed
|
|
||||||
expect(screen.getByTestId("chevron-right-icon")).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId("chevron-down-icon")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows chevron down when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("chevron-down-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Selection", () => {
|
|
||||||
test("renders dropdown content with project options", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("dropdown-content")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.choose_project")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("dropdown-group")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders all project options in dropdown", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
|
|
||||||
// Find project options (excluding the add new project option)
|
|
||||||
const projectOptions = checkboxItems.filter((item) => item.textContent?.includes("Test Project"));
|
|
||||||
expect(projectOptions).toHaveLength(2);
|
|
||||||
|
|
||||||
// Check current project is marked as selected
|
|
||||||
const currentProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 1"));
|
|
||||||
expect(currentProjectOption).toHaveAttribute("data-checked", "true");
|
|
||||||
|
|
||||||
// Check other project is not selected
|
|
||||||
const otherProjectOption = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
|
||||||
expect(otherProjectOption).toHaveAttribute("data-checked", "false");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles project change when clicking dropdown option", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const checkboxItems = screen.getAllByTestId("dropdown-checkbox-item");
|
|
||||||
const project2Option = checkboxItems.find((item) => item.textContent?.includes("Test Project 2"));
|
|
||||||
|
|
||||||
expect(project2Option).toBeInTheDocument();
|
|
||||||
await user.click(project2Option!);
|
|
||||||
|
|
||||||
expect(mockPush).toHaveBeenCalledWith("/projects/proj-2/");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Add New Project", () => {
|
|
||||||
test("shows add new project option when user is owner or manager", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.getByText("common.add_new_project")).toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId("plus-icon")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hides add new project option when user is not owner or manager", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} isOwnerOrManager={false} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
expect(screen.queryByText("common.add_new_project")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens create project modal when within project limit", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Organization: org-1")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Access Control: Allowed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens limit modal when exceeding project limit", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project Limit: 3")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Project Limit Modal", () => {
|
|
||||||
test("shows correct buttons for Formbricks Cloud with non-enterprise plan", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: true,
|
|
||||||
currentOrganization: {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { ...mockOrganization.billing, plan: "startup" } as unknown as TOrganizationBilling,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows correct buttons for self-hosted with active license", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
isFormbricksCloud: false,
|
|
||||||
isLicenseActive: true,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("environments.settings.billing.upgrade")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("common.cancel")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("closes limit modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Limit Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("project-limit-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Create Project Modal", () => {
|
|
||||||
test("closes create project modal correctly", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("create-project-modal")).toBeInTheDocument();
|
|
||||||
|
|
||||||
const closeButton = screen.getByText("Close Create Modal");
|
|
||||||
await user.click(closeButton);
|
|
||||||
|
|
||||||
expect(screen.queryByTestId("create-project-modal")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("passes correct props to create project modal", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} isAccessControlAllowed={false} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
expect(screen.getByText("Access Control: Not Allowed")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge Cases", () => {
|
|
||||||
test("handles single project scenario", () => {
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} projects={[mockProject1]} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("breadcrumb-item")).toBeInTheDocument();
|
|
||||||
expect(screen.getAllByText("Test Project 1")).toHaveLength(2); // trigger + dropdown option
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sets breadcrumb item as active when dropdown is open", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} />);
|
|
||||||
|
|
||||||
// Initially not active
|
|
||||||
let breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "false");
|
|
||||||
|
|
||||||
// Open dropdown
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
// Should be active when dropdown is open
|
|
||||||
breadcrumbItem = screen.getByTestId("breadcrumb-item");
|
|
||||||
expect(breadcrumbItem).toHaveAttribute("data-active", "true");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles project limit of zero", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<ProjectBreadcrumb {...defaultProps} organizationProjectsLimit={0} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
// Should show limit modal even with 0 projects when limit is 0
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
expect(screen.getByText("Project Limit: 0")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles enterprise plan on Formbricks Cloud", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const props = {
|
|
||||||
...defaultProps,
|
|
||||||
projects: [mockProject1, mockProject2, { ...mockProject1, id: "proj-3", name: "Project 3" }],
|
|
||||||
organizationProjectsLimit: 3,
|
|
||||||
currentOrganization: {
|
|
||||||
...mockOrganization,
|
|
||||||
billing: { ...mockOrganization.billing, plan: "enterprise" } as unknown as TOrganizationBilling,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
render(<ProjectBreadcrumb {...props} />);
|
|
||||||
|
|
||||||
const dropdownMenu = screen.getByTestId("dropdown-menu");
|
|
||||||
await user.click(dropdownMenu);
|
|
||||||
|
|
||||||
const addProjectOption = screen.getByText("common.add_new_project");
|
|
||||||
await user.click(addProjectOption);
|
|
||||||
|
|
||||||
// Should show self-hosted style buttons for enterprise plan
|
|
||||||
expect(screen.getByTestId("project-limit-modal")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
|
||||||
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
|
||||||
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
|
||||||
import { useTranslate } from "@tolgee/react";
|
|
||||||
import { ChevronDownIcon, ChevronRightIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
interface ProjectBreadcrumbProps {
|
|
||||||
currentProjectId: string;
|
|
||||||
projects: { id: string; name: string }[];
|
|
||||||
isOwnerOrManager: boolean;
|
|
||||||
organizationProjectsLimit: number;
|
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
isLicenseActive: boolean;
|
|
||||||
currentOrganizationId: string;
|
|
||||||
currentEnvironmentId: string;
|
|
||||||
isAccessControlAllowed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProjectBreadcrumb = ({
|
|
||||||
currentProjectId,
|
|
||||||
projects,
|
|
||||||
isOwnerOrManager,
|
|
||||||
organizationProjectsLimit,
|
|
||||||
isFormbricksCloud,
|
|
||||||
isLicenseActive,
|
|
||||||
currentOrganizationId,
|
|
||||||
currentEnvironmentId,
|
|
||||||
isAccessControlAllowed,
|
|
||||||
}: ProjectBreadcrumbProps) => {
|
|
||||||
const { t } = useTranslate();
|
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
|
||||||
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
|
||||||
const [openLimitModal, setOpenLimitModal] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const currentProject = projects.find((project) => project.id === currentProjectId);
|
|
||||||
|
|
||||||
if (!currentProject) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleProjectChange = (projectId: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
router.push(`/projects/${projectId}/`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddProject = () => {
|
|
||||||
if (projects.length >= organizationProjectsLimit) {
|
|
||||||
setOpenLimitModal(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setOpenCreateProjectModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const LimitModalButtons = (): [ModalButton, ModalButton] => {
|
|
||||||
if (isFormbricksCloud) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: `/environments/${currentEnvironmentId}/settings/billing`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
text: t("environments.settings.billing.upgrade"),
|
|
||||||
href: isLicenseActive
|
|
||||||
? `/environments/${currentEnvironmentId}/settings/enterprise`
|
|
||||||
: "https://formbricks.com/upgrade-self-hosted-license",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: t("common.cancel"),
|
|
||||||
onClick: () => setOpenLimitModal(false),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<BreadcrumbItem isActive={isProjectDropdownOpen}>
|
|
||||||
<DropdownMenu onOpenChange={setIsProjectDropdownOpen}>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
className="flex cursor-pointer items-center gap-1 outline-none"
|
|
||||||
id="projectDropdownTrigger"
|
|
||||||
asChild>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
<span>{currentProject.name}</span>
|
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
|
||||||
{isProjectDropdownOpen ? (
|
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
) : (
|
|
||||||
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
|
||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" />
|
|
||||||
{t("common.choose_project")}
|
|
||||||
</div>
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
{projects.map((proj) => (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
key={proj.id}
|
|
||||||
checked={proj.id === currentProject.id}
|
|
||||||
onClick={() => handleProjectChange(proj.id)}
|
|
||||||
className="cursor-pointer">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{proj.name}</span>
|
|
||||||
</div>
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
{isOwnerOrManager && (
|
|
||||||
<DropdownMenuCheckboxItem
|
|
||||||
onClick={handleAddProject}
|
|
||||||
className="w-full cursor-pointer justify-between">
|
|
||||||
<span>{t("common.add_new_project")}</span>
|
|
||||||
<PlusIcon className="ml-2 h-4 w-4" />
|
|
||||||
</DropdownMenuCheckboxItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
{/* Modals */}
|
|
||||||
{openLimitModal && (
|
|
||||||
<ProjectLimitModal
|
|
||||||
open={openLimitModal}
|
|
||||||
setOpen={setOpenLimitModal}
|
|
||||||
buttons={LimitModalButtons()}
|
|
||||||
projectLimit={organizationProjectsLimit}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{openCreateProjectModal && (
|
|
||||||
<CreateProjectModal
|
|
||||||
open={openCreateProjectModal}
|
|
||||||
setOpen={setOpenCreateProjectModal}
|
|
||||||
organizationId={currentOrganizationId}
|
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -37,7 +37,7 @@ const Page = async (props) => {
|
|||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const Page = async (props) => {
|
|||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -191,7 +191,9 @@ describe("NotionIntegrationPage", () => {
|
|||||||
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
expect(screen.getByTestId("webAppUrl")).toHaveTextContent("test-webapp-url");
|
||||||
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
expect(screen.getByTestId("databaseCount")).toHaveTextContent(mockDatabases.length.toString());
|
||||||
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
expect(screen.getByTestId("locale")).toHaveTextContent("en-US");
|
||||||
expect(screen.getByTestId("go-back")).toHaveTextContent("./");
|
expect(screen.getByTestId("go-back")).toHaveTextContent(
|
||||||
|
`test-webapp-url/environments/${mockProps.params.environmentId}/integrations`
|
||||||
|
);
|
||||||
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
expect(vi.mocked(redirect)).not.toHaveBeenCalled();
|
||||||
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
expect(vi.mocked(getNotionDatabases)).toHaveBeenCalledWith(mockEnvironment.id);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,12 +42,12 @@ const Page = async (props) => {
|
|||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={"./"} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
|
||||||
<NotionWrapper
|
<NotionWrapper
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const Page = async (props) => {
|
|||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
import { getOrganizationsByUserId } from "./organization";
|
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
organization: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Organization", () => {
|
|
||||||
describe("getOrganizationsByUserId", () => {
|
|
||||||
test("should return organizations when found", async () => {
|
|
||||||
const mockOrganizations = [
|
|
||||||
{ id: "org1", name: "Organization 1" },
|
|
||||||
{ id: "org2", name: "Organization 2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(mockOrganizations as any);
|
|
||||||
|
|
||||||
const result = await getOrganizationsByUserId("user1");
|
|
||||||
|
|
||||||
expect(prisma.organization.findMany).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
memberships: {
|
|
||||||
some: {
|
|
||||||
userId: "user1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockOrganizations);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw ResourceNotFoundError when organizations is null", async () => {
|
|
||||||
vi.mocked(prisma.organization.findMany).mockResolvedValue(null as any);
|
|
||||||
|
|
||||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(
|
|
||||||
new ResourceNotFoundError("Organizations by UserId", "user1")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw DatabaseError on Prisma error", async () => {
|
|
||||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
|
||||||
code: "P2002",
|
|
||||||
clientVersion: "5.0.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(prisma.organization.findMany).mockRejectedValue(prismaError);
|
|
||||||
|
|
||||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(new DatabaseError("Database error"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should re-throw unknown errors", async () => {
|
|
||||||
const unknownError = new Error("Unknown error");
|
|
||||||
vi.mocked(prisma.organization.findMany).mockRejectedValue(unknownError);
|
|
||||||
|
|
||||||
await expect(getOrganizationsByUserId("user1")).rejects.toThrow(unknownError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate inputs correctly", async () => {
|
|
||||||
await expect(getOrganizationsByUserId("")).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate userId input with invalid type", async () => {
|
|
||||||
await expect(getOrganizationsByUserId(123 as any)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { validateInputs } from "@/lib/utils/validate";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { cache as reactCache } from "react";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { ZString } from "@formbricks/types/common";
|
|
||||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|
||||||
|
|
||||||
export const getOrganizationsByUserId = reactCache(
|
|
||||||
async (userId: string): Promise<{ id: string; name: string }[]> => {
|
|
||||||
validateInputs([userId, ZString]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const organizations = await prisma.organization.findMany({
|
|
||||||
where: {
|
|
||||||
memberships: {
|
|
||||||
some: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!organizations) {
|
|
||||||
throw new ResourceNotFoundError("Organizations by UserId", userId);
|
|
||||||
}
|
|
||||||
return organizations;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
throw new DatabaseError(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
|
||||||
import { TMembership } from "@formbricks/types/memberships";
|
|
||||||
import { getProjectsByUserId } from "./project";
|
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
|
||||||
prisma: {
|
|
||||||
project: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("Project", () => {
|
|
||||||
describe("getUserProjects", () => {
|
|
||||||
const mockAdminMembership: TMembership = {
|
|
||||||
role: "manager",
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMemberMembership: TMembership = {
|
|
||||||
role: "member",
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
test("should return projects for admin role", async () => {
|
|
||||||
const mockProjects = [
|
|
||||||
{ id: "project1", name: "Project 1" },
|
|
||||||
{ id: "project2", name: "Project 2" },
|
|
||||||
];
|
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
organizationId: "org1",
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockProjects);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return projects for member role with team restrictions", async () => {
|
|
||||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockMemberMembership);
|
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
organizationId: "org1",
|
|
||||||
projectTeams: {
|
|
||||||
some: {
|
|
||||||
team: {
|
|
||||||
teamUsers: {
|
|
||||||
some: {
|
|
||||||
userId: "user1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockProjects);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return empty array when no projects found", async () => {
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
|
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockAdminMembership);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should throw DatabaseError on Prisma error", async () => {
|
|
||||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
|
||||||
code: "P2002",
|
|
||||||
clientVersion: "5.0.0",
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
|
|
||||||
|
|
||||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(
|
|
||||||
new DatabaseError("Database error")
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should re-throw unknown errors", async () => {
|
|
||||||
const unknownError = new Error("Unknown error");
|
|
||||||
vi.mocked(prisma.project.findMany).mockRejectedValue(unknownError);
|
|
||||||
|
|
||||||
await expect(getProjectsByUserId("user1", mockAdminMembership)).rejects.toThrow(unknownError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate inputs correctly", async () => {
|
|
||||||
await expect(getProjectsByUserId(123 as any, mockAdminMembership)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should validate membership input correctly", async () => {
|
|
||||||
const invalidMembership = {} as TMembership;
|
|
||||||
await expect(getProjectsByUserId("user1", invalidMembership)).rejects.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle owner role like manager", async () => {
|
|
||||||
const mockOwnerMembership: TMembership = {
|
|
||||||
role: "owner",
|
|
||||||
organizationId: "org1",
|
|
||||||
userId: "user1",
|
|
||||||
accepted: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockProjects = [{ id: "project1", name: "Project 1" }];
|
|
||||||
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
|
|
||||||
|
|
||||||
const result = await getProjectsByUserId("user1", mockOwnerMembership);
|
|
||||||
|
|
||||||
expect(prisma.project.findMany).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
organizationId: "org1",
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual(mockProjects);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { validateInputs } from "@/lib/utils/validate";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { cache as reactCache } from "react";
|
|
||||||
import { prisma } from "@formbricks/database";
|
|
||||||
import { ZString } from "@formbricks/types/common";
|
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
|
||||||
import { TMembership, ZMembership } from "@formbricks/types/memberships";
|
|
||||||
|
|
||||||
export const getProjectsByUserId = reactCache(
|
|
||||||
async (userId: string, orgMembership: TMembership): Promise<{ id: string; name: string }[]> => {
|
|
||||||
validateInputs([userId, ZString], [orgMembership, ZMembership]);
|
|
||||||
|
|
||||||
let projectWhereClause: Prisma.ProjectWhereInput = {};
|
|
||||||
|
|
||||||
if (orgMembership.role === "member") {
|
|
||||||
projectWhereClause = {
|
|
||||||
projectTeams: {
|
|
||||||
some: {
|
|
||||||
team: {
|
|
||||||
teamUsers: {
|
|
||||||
some: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const projects = await prisma.project.findMany({
|
|
||||||
where: {
|
|
||||||
organizationId: orgMembership.organizationId,
|
|
||||||
...projectWhereClause,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return projects;
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
||||||
throw new DatabaseError(error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
// apps/web/lib/utils/version.test.ts
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { isNewerVersion, parseVersion } from "./utils";
|
|
||||||
|
|
||||||
describe("Version utilities", () => {
|
|
||||||
describe("parseVersion", () => {
|
|
||||||
test("should parse valid semantic versions", () => {
|
|
||||||
expect(parseVersion("1.2.3")).toEqual({
|
|
||||||
major: 1,
|
|
||||||
minor: 2,
|
|
||||||
patch: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(parseVersion("v2.0.0-beta.1")).toEqual({
|
|
||||||
major: 2,
|
|
||||||
minor: 0,
|
|
||||||
patch: 0,
|
|
||||||
prerelease: "beta.1",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should return null for invalid versions", () => {
|
|
||||||
expect(parseVersion("invalid")).toBeNull();
|
|
||||||
expect(parseVersion("1.2")).toEqual({
|
|
||||||
major: 1,
|
|
||||||
minor: 2,
|
|
||||||
patch: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isNewerVersion", () => {
|
|
||||||
test("should correctly identify newer versions", () => {
|
|
||||||
expect(isNewerVersion("1.0.0", "1.0.1")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0", "1.1.0")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0", "2.0.0")).toBe(true);
|
|
||||||
|
|
||||||
expect(isNewerVersion("1.0.1", "1.0.0")).toBe(false);
|
|
||||||
expect(isNewerVersion("1.0.0", "1.0.0")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle version prefixes", () => {
|
|
||||||
expect(isNewerVersion("v1.0.0", "v1.0.1")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0", "v1.0.1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should handle prerelease versions", () => {
|
|
||||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0", "1.0.0-beta.1")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should correctly compare prerelease versions with numeric parts", () => {
|
|
||||||
// Test the main issue: rc.5 vs rc.10
|
|
||||||
expect(isNewerVersion("3.17.0-rc.5", "3.17.0-rc.10")).toBe(true);
|
|
||||||
expect(isNewerVersion("3.17.0-rc.10", "3.17.0-rc.5")).toBe(false);
|
|
||||||
|
|
||||||
// Test other numeric comparisons
|
|
||||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta.2")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0-alpha.9", "1.0.0-alpha.10")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0-rc.99", "1.0.0-rc.100")).toBe(true);
|
|
||||||
|
|
||||||
// Test mixed alphanumeric comparisons
|
|
||||||
expect(isNewerVersion("1.0.0-alpha.1", "1.0.0-beta.1")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-rc.1")).toBe(true);
|
|
||||||
|
|
||||||
// Test versions with different number of parts
|
|
||||||
expect(isNewerVersion("1.0.0-beta", "1.0.0-beta.1")).toBe(true);
|
|
||||||
expect(isNewerVersion("1.0.0-beta.1", "1.0.0-beta")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should treat two-part versions as patch=0 (e.g., 3.16 == 3.16.0)", () => {
|
|
||||||
expect(isNewerVersion("3.16", "3.16.0")).toBe(false);
|
|
||||||
expect(isNewerVersion("3.16.0", "3.16")).toBe(false);
|
|
||||||
expect(isNewerVersion("3.16", "3.16.1")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should ignore build metadata for precedence", () => {
|
|
||||||
expect(isNewerVersion("1.0.0+001", "1.0.0+002")).toBe(false);
|
|
||||||
expect(isNewerVersion("1.0.0", "1.0.0+exp.sha.5114f85")).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
export interface VersionInfo {
|
|
||||||
major: number;
|
|
||||||
minor: number;
|
|
||||||
patch: number;
|
|
||||||
prerelease?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const parseVersion = (version: string): VersionInfo | null => {
|
|
||||||
// Remove 'v' prefix if present
|
|
||||||
const cleanVersion = version.replace(/^v/, "");
|
|
||||||
|
|
||||||
// Regex for semantic versioning with optional patch and prerelease (no build metadata)
|
|
||||||
// Supports both 2-part (1.2) and 3-part (1.2.3) versions
|
|
||||||
const semverRegex = /^(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
|
|
||||||
|
|
||||||
const match = semverRegex.exec(cleanVersion);
|
|
||||||
if (!match) {
|
|
||||||
console.warn(`Invalid version format: ${version}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
major: parseInt(match[1], 10),
|
|
||||||
minor: parseInt(match[2], 10),
|
|
||||||
patch: match[3] ? parseInt(match[3], 10) : 0, // Default to 0 if patch is missing
|
|
||||||
prerelease: match[4],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const comparePrereleaseVersions = (current: string, latest: string): number => {
|
|
||||||
const currentParts = current.split(".");
|
|
||||||
const latestParts = latest.split(".");
|
|
||||||
const max = Math.max(currentParts.length, latestParts.length);
|
|
||||||
|
|
||||||
const isNumeric = (s: string) => /^\d+$/.test(s);
|
|
||||||
|
|
||||||
const comparePart = (a?: string, b?: string): number => {
|
|
||||||
if (!a && b) return 1; // latest has more segments → newer
|
|
||||||
if (a && !b) return -1; // current has more segments → older
|
|
||||||
if (!a && !b) return 0;
|
|
||||||
|
|
||||||
const aNum = isNumeric(a!);
|
|
||||||
const bNum = isNumeric(b!);
|
|
||||||
if (aNum && bNum) return parseInt(b!, 10) - parseInt(a!, 10);
|
|
||||||
return b!.localeCompare(a!);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < max; i++) {
|
|
||||||
const diff = comparePart(currentParts[i], latestParts[i]);
|
|
||||||
if (diff !== 0) return diff;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const compareVersions = (current: string, latest: string): number => {
|
|
||||||
const currentVersion = parseVersion(current);
|
|
||||||
const latestVersion = parseVersion(latest);
|
|
||||||
|
|
||||||
// If either version is invalid, treat as different
|
|
||||||
if (!currentVersion || !latestVersion) {
|
|
||||||
return current === latest ? 0 : -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compare major.minor.patch
|
|
||||||
const majorDiff = latestVersion.major - currentVersion.major;
|
|
||||||
if (majorDiff !== 0) return majorDiff;
|
|
||||||
|
|
||||||
const minorDiff = latestVersion.minor - currentVersion.minor;
|
|
||||||
if (minorDiff !== 0) return minorDiff;
|
|
||||||
|
|
||||||
const patchDiff = latestVersion.patch - currentVersion.patch;
|
|
||||||
if (patchDiff !== 0) return patchDiff;
|
|
||||||
|
|
||||||
// Handle prerelease versions
|
|
||||||
if (!currentVersion.prerelease && !latestVersion.prerelease) {
|
|
||||||
return 0; // Both are stable, same version
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentVersion.prerelease && latestVersion.prerelease) {
|
|
||||||
return -1; // Current is stable, latest is prerelease - current is "newer"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentVersion.prerelease && !latestVersion.prerelease) {
|
|
||||||
return 1; // Current is prerelease, latest is stable - latest is newer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both are prerelease, compare properly
|
|
||||||
return comparePrereleaseVersions(currentVersion.prerelease!, latestVersion.prerelease!);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isNewerVersion = (current: string, latest: string): boolean => {
|
|
||||||
return compareVersions(current, latest) > 0;
|
|
||||||
};
|
|
||||||
@@ -23,10 +23,6 @@ vi.mock("next/navigation", () => ({
|
|||||||
redirect: vi.fn(),
|
redirect: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/constants", () => ({
|
|
||||||
IS_FORMBRICKS_CLOUD: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("EnvironmentPage", () => {
|
describe("EnvironmentPage", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -41,6 +37,7 @@ describe("EnvironmentPage", () => {
|
|||||||
id: mockUserId,
|
id: mockUserId,
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
@@ -12,11 +11,7 @@ const EnvironmentPage = async (props) => {
|
|||||||
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
const { isBilling } = getAccessFlags(currentUserMembership?.role);
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
|
||||||
} else {
|
|
||||||
return redirect(`/environments/${params.environmentId}/settings/enterprise`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(`/environments/${params.environmentId}/surveys`);
|
return redirect(`/environments/${params.environmentId}/surveys`);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
verifyUserPassword,
|
verifyUserPassword,
|
||||||
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/lib/user";
|
||||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||||
|
import { deleteFile } from "@/lib/storage/service";
|
||||||
|
import { getFileNameWithIdFromUrl } from "@/lib/storage/utils";
|
||||||
import { getUser, updateUser } from "@/lib/user/service";
|
import { getUser, updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
@@ -13,6 +15,8 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
|||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
import { sendForgotPasswordEmail, sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
import { AuthenticationError, AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import {
|
import {
|
||||||
TUserPersonalInfoUpdateInput,
|
TUserPersonalInfoUpdateInput,
|
||||||
@@ -93,6 +97,58 @@ export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalIn
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ZUpdateAvatarAction = z.object({
|
||||||
|
avatarUrl: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateAvatarAction = authenticatedActionClient.schema(ZUpdateAvatarAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"user",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||||
|
const oldObject = await getUser(ctx.user.id);
|
||||||
|
const result = await updateUser(ctx.user.id, { imageUrl: parsedInput.avatarUrl });
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const ZRemoveAvatarAction = z.object({
|
||||||
|
environmentId: ZId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeAvatarAction = authenticatedActionClient.schema(ZRemoveAvatarAction).action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"user",
|
||||||
|
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||||
|
const oldObject = await getUser(ctx.user.id);
|
||||||
|
const imageUrl = ctx.user.imageUrl;
|
||||||
|
if (!imageUrl) {
|
||||||
|
throw new Error("Image not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = getFileNameWithIdFromUrl(imageUrl);
|
||||||
|
if (!fileName) {
|
||||||
|
throw new Error("Invalid filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletionResult = await deleteFile(parsedInput.environmentId, "public", fileName);
|
||||||
|
if (!deletionResult.success) {
|
||||||
|
throw new Error("Deletion failed");
|
||||||
|
}
|
||||||
|
const result = await updateUser(ctx.user.id, { imageUrl: null });
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
export const resetPasswordAction = authenticatedActionClient.action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"passwordReset",
|
"passwordReset",
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import * as profileActions from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||||
|
import * as fileUploadHooks from "@/app/lib/fileUpload";
|
||||||
|
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { EditProfileAvatarForm } from "./EditProfileAvatarForm";
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||||
|
ProfileAvatar: ({ imageUrl }) => <div data-testid="profile-avatar">{imageUrl || "No Avatar"}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions", () => ({
|
||||||
|
updateAvatarAction: vi.fn(),
|
||||||
|
removeAvatarAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/lib/fileUpload", () => ({
|
||||||
|
handleFileUpload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSession: Session = {
|
||||||
|
user: { id: "user-id" },
|
||||||
|
expires: "session-expires-at",
|
||||||
|
};
|
||||||
|
const environmentId = "test-env-id";
|
||||||
|
|
||||||
|
describe("EditProfileAvatarForm", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(profileActions.updateAvatarAction).mockResolvedValue({});
|
||||||
|
vi.mocked(profileActions.removeAvatarAction).mockResolvedValue({});
|
||||||
|
vi.mocked(fileUploadHooks.handleFileUpload).mockResolvedValue({
|
||||||
|
url: "new-avatar.jpg",
|
||||||
|
error: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly without an existing image", () => {
|
||||||
|
render(<EditProfileAvatarForm session={mockSession} environmentId={environmentId} imageUrl={null} />);
|
||||||
|
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("No Avatar");
|
||||||
|
expect(screen.getByText("environments.settings.profile.upload_image")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("environments.settings.profile.remove_image")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders correctly with an existing image", () => {
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("profile-avatar")).toHaveTextContent("existing-avatar.jpg");
|
||||||
|
expect(screen.getByText("environments.settings.profile.change_image")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("environments.settings.profile.remove_image")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles image removal successfully", async () => {
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(profileActions.removeAvatarAction).toHaveBeenCalledWith({ environmentId });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows error if removeAvatarAction fails", async () => {
|
||||||
|
vi.mocked(profileActions.removeAvatarAction).mockRejectedValue(new Error("API error"));
|
||||||
|
render(
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={mockSession}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl="existing-avatar.jpg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const removeButton = screen.getByText("environments.settings.profile.remove_image");
|
||||||
|
await userEvent.click(removeButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(vi.mocked(toast.error)).toHaveBeenCalledWith(
|
||||||
|
"environments.settings.profile.avatar_update_failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+178
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
removeAvatarAction,
|
||||||
|
updateAvatarAction,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/actions";
|
||||||
|
import { handleFileUpload } from "@/app/lib/fileUpload";
|
||||||
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { FormError, FormField, FormItem, FormProvider } from "@/modules/ui/components/form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useTranslate } from "@tolgee/react";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
interface EditProfileAvatarFormProps {
|
||||||
|
session: Session;
|
||||||
|
environmentId: string;
|
||||||
|
imageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditProfileAvatarForm = ({ session, environmentId, imageUrl }: EditProfileAvatarFormProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslate();
|
||||||
|
const fileSchema =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? z
|
||||||
|
.instanceof(FileList)
|
||||||
|
.refine((files) => files.length === 1, t("environments.settings.profile.you_must_select_a_file"))
|
||||||
|
.refine((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||||
|
return allowedTypes.includes(file.type);
|
||||||
|
}, t("environments.settings.profile.invalid_file_type"))
|
||||||
|
.refine((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
return file.size <= maxSize;
|
||||||
|
}, t("environments.settings.profile.file_size_must_be_less_than_10mb"))
|
||||||
|
: z.any();
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
file: fileSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
mode: "onChange",
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleUpload = async (file: File, environmentId: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (imageUrl) {
|
||||||
|
// If avatar image already exists, then remove it before update action
|
||||||
|
await removeAvatarAction({ environmentId });
|
||||||
|
}
|
||||||
|
const { url, error } = await handleFileUpload(file, environmentId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error(error);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAvatarAction({ avatarUrl: url });
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeAvatarAction({ environmentId });
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t("environments.settings.profile.avatar_update_failed"));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
form.reset();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormValues) => {
|
||||||
|
const file = data.file[0];
|
||||||
|
if (file) {
|
||||||
|
await handleUpload(file, environmentId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||||
|
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ProfileAvatar userId={session.user.id} imageUrl={imageUrl} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-4">
|
||||||
|
<FormField
|
||||||
|
name="file"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormItem>
|
||||||
|
<div className="flex">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="mr-2"
|
||||||
|
variant={!!fieldState.error?.message ? "destructive" : "secondary"}
|
||||||
|
onClick={() => {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}}>
|
||||||
|
{imageUrl
|
||||||
|
? t("environments.settings.profile.change_image")
|
||||||
|
: t("environments.settings.profile.upload_image")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="hiddenFileInput"
|
||||||
|
ref={(e) => {
|
||||||
|
field.ref(e);
|
||||||
|
inputRef.current = e;
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
accept="image/jpeg, image/png, image/webp"
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e.target.files);
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{imageUrl && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="mr-2"
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRemove}>
|
||||||
|
{t("environments.settings.profile.remove_image")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+6
-3
@@ -49,12 +49,15 @@ describe("Loading", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadingCards = screen.getAllByTestId("loading-card");
|
const loadingCards = screen.getAllByTestId("loading-card");
|
||||||
expect(loadingCards).toHaveLength(2);
|
expect(loadingCards).toHaveLength(3);
|
||||||
|
|
||||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
|
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.personal_information");
|
||||||
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
|
expect(loadingCards[0]).toHaveTextContent("environments.settings.profile.update_personal_info");
|
||||||
|
|
||||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.delete_account");
|
expect(loadingCards[1]).toHaveTextContent("common.avatar");
|
||||||
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
expect(loadingCards[1]).toHaveTextContent("environments.settings.profile.organization_identification");
|
||||||
|
|
||||||
|
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.delete_account");
|
||||||
|
expect(loadingCards[2]).toHaveTextContent("environments.settings.profile.confirm_delete_account");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ const Loading = () => {
|
|||||||
{ classes: "h-6 w-64" },
|
{ classes: "h-6 w-64" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("common.avatar"),
|
||||||
|
description: t("environments.settings.profile.organization_identification"),
|
||||||
|
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.settings.profile.delete_account"),
|
title: t("environments.settings.profile.delete_account"),
|
||||||
description: t("environments.settings.profile.confirm_delete_account"),
|
description: t("environments.settings.profile.confirm_delete_account"),
|
||||||
|
|||||||
+9
-3
@@ -55,6 +55,11 @@ vi.mock(
|
|||||||
vi.mock("./components/DeleteAccount", () => ({
|
vi.mock("./components/DeleteAccount", () => ({
|
||||||
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
|
DeleteAccount: ({ user }) => <div data-testid="delete-account">DeleteAccount: {user.id}</div>,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("./components/EditProfileAvatarForm", () => ({
|
||||||
|
EditProfileAvatarForm: ({ _, environmentId }) => (
|
||||||
|
<div data-testid="edit-profile-avatar-form">EditProfileAvatarForm: {environmentId}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
vi.mock("./components/EditProfileDetailsForm", () => ({
|
vi.mock("./components/EditProfileDetailsForm", () => ({
|
||||||
EditProfileDetailsForm: ({ user }) => (
|
EditProfileDetailsForm: ({ user }) => (
|
||||||
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
<div data-testid="edit-profile-details-form">EditProfileDetailsForm: {user.id}</div>
|
||||||
@@ -68,6 +73,7 @@ const mockUser = {
|
|||||||
id: "user-123",
|
id: "user-123",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
|
imageUrl: "http://example.com/avatar.png",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
notificationSettings: { alert: {}, unsubscribedOrganizationIds: [] },
|
||||||
@@ -111,12 +117,12 @@ describe("ProfilePage", () => {
|
|||||||
"AccountSettingsNavbar: env-123 profile"
|
"AccountSettingsNavbar: env-123 profile"
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
|
expect(screen.getByTestId("edit-profile-details-form")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("edit-profile-avatar-form")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
|
expect(screen.getByTestId("account-security")).toBeInTheDocument(); // Shown because 2FA license is enabled
|
||||||
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("upgrade-prompt")).not.toBeInTheDocument();
|
||||||
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
|
expect(screen.getByTestId("delete-account")).toBeInTheDocument();
|
||||||
// Check for IdBadge content
|
// Use a regex to match the text content, allowing for variable whitespace
|
||||||
expect(screen.getByText("common.profile_id")).toBeInTheDocument();
|
expect(screen.getByText(new RegExp(`common\\.profile\\s*:\\s*${mockUser.id}`))).toBeInTheDocument(); // SettingsId
|
||||||
expect(screen.getByText(mockUser.id)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -5,13 +5,14 @@ import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/servi
|
|||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||||
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
import { UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { DeleteAccount } from "./components/DeleteAccount";
|
import { DeleteAccount } from "./components/DeleteAccount";
|
||||||
|
import { EditProfileAvatarForm } from "./components/EditProfileAvatarForm";
|
||||||
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
import { EditProfileDetailsForm } from "./components/EditProfileDetailsForm";
|
||||||
|
|
||||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
@@ -49,6 +50,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
isPasswordResetEnabled={isPasswordResetEnabled}
|
isPasswordResetEnabled={isPasswordResetEnabled}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
<SettingsCard
|
||||||
|
title={t("common.avatar")}
|
||||||
|
description={t("environments.settings.profile.organization_identification")}>
|
||||||
|
{user && (
|
||||||
|
<EditProfileAvatarForm
|
||||||
|
session={session}
|
||||||
|
environmentId={environmentId}
|
||||||
|
imageUrl={user.imageUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsCard>
|
||||||
{user.identityProvider === "email" && (
|
{user.identityProvider === "email" && (
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={t("common.security")}
|
title={t("common.security")}
|
||||||
@@ -91,7 +103,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
|
<SettingsId title={t("common.profile")} id={user.id}></SettingsId>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
+13
-13
@@ -34,19 +34,6 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
current: pathname?.includes("/general"),
|
current: pathname?.includes("/general"),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "teams",
|
|
||||||
label: t("common.teams"),
|
|
||||||
href: `/environments/${environmentId}/settings/teams`,
|
|
||||||
current: pathname?.includes("/teams"),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "api-keys",
|
|
||||||
label: t("common.api_keys"),
|
|
||||||
href: `/environments/${environmentId}/settings/api-keys`,
|
|
||||||
current: pathname?.includes("/api-keys"),
|
|
||||||
hidden: !isOwner,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
@@ -54,6 +41,12 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
hidden: !isFormbricksCloud || loading,
|
hidden: !isFormbricksCloud || loading,
|
||||||
current: pathname?.includes("/billing"),
|
current: pathname?.includes("/billing"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "teams",
|
||||||
|
label: t("common.teams"),
|
||||||
|
href: `/environments/${environmentId}/settings/teams`,
|
||||||
|
current: pathname?.includes("/teams"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
@@ -61,6 +54,13 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
hidden: isFormbricksCloud || isPricingDisabled,
|
hidden: isFormbricksCloud || isPricingDisabled,
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "api-keys",
|
||||||
|
label: t("common.api_keys"),
|
||||||
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
|
current: pathname?.includes("/api-keys"),
|
||||||
|
hidden: !isOwner,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||||
|
|||||||
+1
@@ -126,6 +126,7 @@ const mockUser = {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
notificationSettings: { alert: {} },
|
notificationSettings: { alert: {} },
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ const Page = async (props) => {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (IS_FORMBRICKS_CLOUD) {
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
return notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
const { isMember, currentUserMembership } = await getEnvironmentAuth(params.environmentId);
|
||||||
@@ -22,7 +22,7 @@ const Page = async (props) => {
|
|||||||
const isPricingDisabled = isMember;
|
const isPricingDisabled = isMember;
|
||||||
|
|
||||||
if (isPricingDisabled) {
|
if (isPricingDisabled) {
|
||||||
return notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
const { active: isEnterpriseEdition } = await getEnterpriseLicense();
|
||||||
|
|||||||
+5
-6
@@ -5,7 +5,7 @@ import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/lice
|
|||||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
@@ -78,8 +78,8 @@ vi.mock("./components/DeleteOrganization", () => ({
|
|||||||
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
|
DeleteOrganization: vi.fn(() => <div>DeleteOrganization</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
vi.mock("@/modules/ui/components/settings-id", () => ({
|
||||||
IdBadge: vi.fn(() => <div>IdBadge</div>),
|
SettingsId: vi.fn(() => <div>SettingsId</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
@@ -156,11 +156,10 @@ describe("Page", () => {
|
|||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
expect(IdBadge).toHaveBeenCalledWith(
|
expect(SettingsId).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
|
title: "common.organization_id",
|
||||||
id: mockEnvironmentAuth.organization.id,
|
id: mockEnvironmentAuth.organization.id,
|
||||||
label: "common.organization_id",
|
|
||||||
variant: "column",
|
|
||||||
},
|
},
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-2
@@ -4,9 +4,9 @@ import { getUser } from "@/lib/user/service";
|
|||||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
@@ -70,7 +70,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
<SettingsId title={t("common.organization_id")} id={organization.id}></SettingsId>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-2
@@ -60,6 +60,7 @@ const mockResponses = [
|
|||||||
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
|
userAgent: { browser: "Chrome", os: "Mac OS", device: "Desktop" },
|
||||||
url: "http://localhost:3000",
|
url: "http://localhost:3000",
|
||||||
},
|
},
|
||||||
|
notes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
} as unknown as TResponse,
|
} as unknown as TResponse,
|
||||||
{
|
{
|
||||||
@@ -73,6 +74,7 @@ const mockResponses = [
|
|||||||
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
|
userAgent: { browser: "Firefox", os: "Windows", device: "Desktop" },
|
||||||
url: "http://localhost:3000/page2",
|
url: "http://localhost:3000/page2",
|
||||||
},
|
},
|
||||||
|
notes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
} as unknown as TResponse,
|
} as unknown as TResponse,
|
||||||
{
|
{
|
||||||
@@ -86,6 +88,7 @@ const mockResponses = [
|
|||||||
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
|
userAgent: { browser: "Safari", os: "iOS", device: "Mobile" },
|
||||||
url: "http://localhost:3000/page3",
|
url: "http://localhost:3000/page3",
|
||||||
},
|
},
|
||||||
|
notes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
} as unknown as TResponse,
|
} as unknown as TResponse,
|
||||||
] as unknown as TResponse[];
|
] as unknown as TResponse[];
|
||||||
@@ -128,6 +131,7 @@ const mockUser = {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -145,7 +149,7 @@ const mockLocale: TUserLocale = "en-US";
|
|||||||
|
|
||||||
const mockSetSelectedResponseId = vi.fn();
|
const mockSetSelectedResponseId = vi.fn();
|
||||||
const mockUpdateResponse = vi.fn();
|
const mockUpdateResponse = vi.fn();
|
||||||
const mockUpdateResponseList = vi.fn();
|
const mockDeleteResponses = vi.fn();
|
||||||
const mockSetOpen = vi.fn();
|
const mockSetOpen = vi.fn();
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
@@ -157,7 +161,7 @@ const defaultProps = {
|
|||||||
user: mockUser,
|
user: mockUser,
|
||||||
environmentTags: mockEnvironmentTags,
|
environmentTags: mockEnvironmentTags,
|
||||||
updateResponse: mockUpdateResponse,
|
updateResponse: mockUpdateResponse,
|
||||||
updateResponseList: mockUpdateResponseList,
|
deleteResponses: mockDeleteResponses,
|
||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
open: true,
|
open: true,
|
||||||
setOpen: mockSetOpen,
|
setOpen: mockSetOpen,
|
||||||
|
|||||||
+4
-3
@@ -18,7 +18,7 @@ interface ResponseCardModalProps {
|
|||||||
user?: TUser;
|
user?: TUser;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||||
updateResponseList: (responseIds: string[]) => void;
|
deleteResponses: (responseIds: string[]) => void;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
@@ -34,7 +34,7 @@ export const ResponseCardModal = ({
|
|||||||
user,
|
user,
|
||||||
environmentTags,
|
environmentTags,
|
||||||
updateResponse,
|
updateResponse,
|
||||||
updateResponseList,
|
deleteResponses,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
@@ -82,11 +82,12 @@ export const ResponseCardModal = ({
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
response={responses[currentIndex]}
|
response={responses[currentIndex]}
|
||||||
user={user}
|
user={user}
|
||||||
|
pageType="response"
|
||||||
environment={environment}
|
environment={environment}
|
||||||
environmentTags={environmentTags}
|
environmentTags={environmentTags}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
updateResponseList={updateResponseList}
|
deleteResponses={deleteResponses}
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
setSelectedResponseId={setSelectedResponseId}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+17
-14
@@ -117,6 +117,17 @@ const mockResponses: TResponse[] = [
|
|||||||
singleUseId: null,
|
singleUseId: null,
|
||||||
ttc: {},
|
ttc: {},
|
||||||
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
|
tags: [{ id: "tag1", name: "Tag1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() }],
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
id: "note1",
|
||||||
|
text: "Note 1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
isResolved: false,
|
||||||
|
isEdited: false,
|
||||||
|
user: { id: "user1", name: "User 1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
variables: { var1: "Response Var Value" },
|
variables: { var1: "Response Var Value" },
|
||||||
language: "en",
|
language: "en",
|
||||||
contact: null,
|
contact: null,
|
||||||
@@ -133,6 +144,7 @@ const mockResponses: TResponse[] = [
|
|||||||
singleUseId: null,
|
singleUseId: null,
|
||||||
ttc: {},
|
ttc: {},
|
||||||
tags: [],
|
tags: [],
|
||||||
|
notes: [],
|
||||||
variables: {},
|
variables: {},
|
||||||
language: "de",
|
language: "de",
|
||||||
contact: null,
|
contact: null,
|
||||||
@@ -145,6 +157,7 @@ const mockUser = {
|
|||||||
name: "Test User",
|
name: "Test User",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
emailVerified: new Date(),
|
emailVerified: new Date(),
|
||||||
|
imageUrl: "",
|
||||||
twoFactorEnabled: false,
|
twoFactorEnabled: false,
|
||||||
identityProvider: "email",
|
identityProvider: "email",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -176,7 +189,7 @@ const defaultProps = {
|
|||||||
isReadOnly: false,
|
isReadOnly: false,
|
||||||
fetchNextPage: vi.fn(),
|
fetchNextPage: vi.fn(),
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
updateResponseList: vi.fn(),
|
deleteResponses: vi.fn(),
|
||||||
updateResponse: vi.fn(),
|
updateResponse: vi.fn(),
|
||||||
isFetchingFirstPage: false,
|
isFetchingFirstPage: false,
|
||||||
locale: mockLocale,
|
locale: mockLocale,
|
||||||
@@ -221,17 +234,12 @@ describe("ResponseDataView", () => {
|
|||||||
status: "Completed",
|
status: "Completed",
|
||||||
responseId: "response1",
|
responseId: "response1",
|
||||||
tags: mockResponses[0].tags,
|
tags: mockResponses[0].tags,
|
||||||
|
notes: mockResponses[0].notes,
|
||||||
variables: { var1: "Response Var Value" },
|
variables: { var1: "Response Var Value" },
|
||||||
verifiedEmail: "test@example.com",
|
verifiedEmail: "test@example.com",
|
||||||
language: "en",
|
language: "en",
|
||||||
person: null,
|
person: null,
|
||||||
contactAttributes: null,
|
contactAttributes: null,
|
||||||
meta: {
|
|
||||||
url: "http://localhost",
|
|
||||||
userAgent: {
|
|
||||||
browser: "test-agent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
responseData: {
|
responseData: {
|
||||||
@@ -241,17 +249,12 @@ describe("ResponseDataView", () => {
|
|||||||
status: "Not Completed",
|
status: "Not Completed",
|
||||||
responseId: "response2",
|
responseId: "response2",
|
||||||
tags: [],
|
tags: [],
|
||||||
|
notes: [],
|
||||||
variables: {},
|
variables: {},
|
||||||
verifiedEmail: "",
|
verifiedEmail: "",
|
||||||
language: "de",
|
language: "de",
|
||||||
person: null,
|
person: null,
|
||||||
contactAttributes: null,
|
contactAttributes: null,
|
||||||
meta: {
|
|
||||||
url: "http://localhost",
|
|
||||||
userAgent: {
|
|
||||||
browser: "test-agent-2",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -264,7 +267,7 @@ describe("ResponseDataView", () => {
|
|||||||
expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment);
|
expect(responseTableMock.mock.calls[0][0].environment).toEqual(mockEnvironment);
|
||||||
expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage);
|
expect(responseTableMock.mock.calls[0][0].fetchNextPage).toBe(defaultProps.fetchNextPage);
|
||||||
expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true);
|
expect(responseTableMock.mock.calls[0][0].hasMore).toBe(true);
|
||||||
expect(responseTableMock.mock.calls[0][0].updateResponseList).toBe(defaultProps.updateResponseList);
|
expect(responseTableMock.mock.calls[0][0].deleteResponses).toBe(defaultProps.deleteResponses);
|
||||||
expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse);
|
expect(responseTableMock.mock.calls[0][0].updateResponse).toBe(defaultProps.updateResponse);
|
||||||
expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false);
|
expect(responseTableMock.mock.calls[0][0].isFetchingFirstPage).toBe(false);
|
||||||
expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale);
|
expect(responseTableMock.mock.calls[0][0].locale).toBe(mockLocale);
|
||||||
|
|||||||
+9
-17
@@ -4,27 +4,24 @@ import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/
|
|||||||
import { TFnType, useTranslate } from "@tolgee/react";
|
import { TFnType, useTranslate } from "@tolgee/react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TResponse, TResponseDataValue, TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TResponseDataValue, TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
|
|
||||||
interface ResponseDataViewProps {
|
interface ResponseDataViewProps {
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
responses: TResponseWithQuotas[];
|
responses: TResponse[];
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
fetchNextPage: () => void;
|
fetchNextPage: () => void;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
updateResponseList: (responseIds: string[]) => void;
|
deleteResponses: (responseIds: string[]) => void;
|
||||||
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
|
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||||
isFetchingFirstPage: boolean;
|
isFetchingFirstPage: boolean;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isQuotasAllowed: boolean;
|
|
||||||
quotas: TSurveyQuota[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
@@ -50,7 +47,7 @@ export const formatContactInfoData = (responseValue: TResponseDataValue): Record
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export const extractResponseData = (response: TResponseWithQuotas, survey: TSurvey): Record<string, any> => {
|
export const extractResponseData = (response: TResponse, survey: TSurvey): Record<string, any> => {
|
||||||
let responseData: Record<string, any> = {};
|
let responseData: Record<string, any> = {};
|
||||||
|
|
||||||
survey.questions.forEach((question) => {
|
survey.questions.forEach((question) => {
|
||||||
@@ -81,7 +78,7 @@ export const extractResponseData = (response: TResponseWithQuotas, survey: TSurv
|
|||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export const mapResponsesToTableData = (
|
export const mapResponsesToTableData = (
|
||||||
responses: TResponseWithQuotas[],
|
responses: TResponse[],
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
t: TFnType
|
t: TFnType
|
||||||
): TResponseTableData[] => {
|
): TResponseTableData[] => {
|
||||||
@@ -93,6 +90,7 @@ export const mapResponsesToTableData = (
|
|||||||
: t("environments.surveys.responses.not_completed"),
|
: t("environments.surveys.responses.not_completed"),
|
||||||
responseId: response.id,
|
responseId: response.id,
|
||||||
tags: response.tags,
|
tags: response.tags,
|
||||||
|
notes: response.notes,
|
||||||
variables: survey.variables.reduce(
|
variables: survey.variables.reduce(
|
||||||
(acc, curr) => {
|
(acc, curr) => {
|
||||||
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
|
return Object.assign(acc, { [curr.id]: response.variables[curr.id] });
|
||||||
@@ -103,8 +101,6 @@ export const mapResponsesToTableData = (
|
|||||||
language: response.language,
|
language: response.language,
|
||||||
person: response.contact,
|
person: response.contact,
|
||||||
contactAttributes: response.contactAttributes,
|
contactAttributes: response.contactAttributes,
|
||||||
meta: response.meta,
|
|
||||||
quotas: response.quotas?.map((quota) => quota.name),
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,12 +113,10 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasMore,
|
hasMore,
|
||||||
updateResponseList,
|
deleteResponses,
|
||||||
updateResponse,
|
updateResponse,
|
||||||
isFetchingFirstPage,
|
isFetchingFirstPage,
|
||||||
locale,
|
locale,
|
||||||
isQuotasAllowed,
|
|
||||||
quotas,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const data = mapResponsesToTableData(responses, survey, t);
|
const data = mapResponsesToTableData(responses, survey, t);
|
||||||
@@ -139,12 +133,10 @@ export const ResponseDataView: React.FC<ResponseDataViewProps> = ({
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
updateResponseList={updateResponseList}
|
deleteResponses={deleteResponses}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
isFetchingFirstPage={isFetchingFirstPage}
|
isFetchingFirstPage={isFetchingFirstPage}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
|
||||||
quotas={quotas}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-1
@@ -107,6 +107,7 @@ const mockResponses: TResponse[] = [
|
|||||||
finished: true,
|
finished: true,
|
||||||
data: {},
|
data: {},
|
||||||
meta: { userAgent: {} },
|
meta: { userAgent: {} },
|
||||||
|
notes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
} as unknown as TResponse,
|
} as unknown as TResponse,
|
||||||
{
|
{
|
||||||
@@ -117,6 +118,7 @@ const mockResponses: TResponse[] = [
|
|||||||
finished: true,
|
finished: true,
|
||||||
data: {},
|
data: {},
|
||||||
meta: { userAgent: {} },
|
meta: { userAgent: {} },
|
||||||
|
notes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
} as unknown as TResponse,
|
} as unknown as TResponse,
|
||||||
];
|
];
|
||||||
@@ -187,7 +189,7 @@ describe("ResponsePage", () => {
|
|||||||
).mock.calls[0][0];
|
).mock.calls[0][0];
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
responseDataViewProps.updateResponseList(["response1"]);
|
responseDataViewProps.deleteResponses(["response1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if ResponseDataView is re-rendered with updated responses
|
// Check if ResponseDataView is re-rendered with updated responses
|
||||||
|
|||||||
+11
-16
@@ -9,8 +9,7 @@ import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
|||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TResponseWithQuotas } from "@formbricks/types/responses";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
@@ -24,8 +23,6 @@ interface ResponsePageProps {
|
|||||||
responsesPerPage: number;
|
responsesPerPage: number;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
isQuotasAllowed: boolean;
|
|
||||||
quotas: TSurveyQuota[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponsePage = ({
|
export const ResponsePage = ({
|
||||||
@@ -37,10 +34,8 @@ export const ResponsePage = ({
|
|||||||
responsesPerPage,
|
responsesPerPage,
|
||||||
locale,
|
locale,
|
||||||
isReadOnly,
|
isReadOnly,
|
||||||
isQuotasAllowed,
|
|
||||||
quotas,
|
|
||||||
}: ResponsePageProps) => {
|
}: ResponsePageProps) => {
|
||||||
const [responses, setResponses] = useState<TResponseWithQuotas[]>([]);
|
const [responses, setResponses] = useState<TResponse[]>([]);
|
||||||
const [page, setPage] = useState<number>(1);
|
const [page, setPage] = useState<number>(1);
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true);
|
const [hasMore, setHasMore] = useState<boolean>(true);
|
||||||
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
const [isFetchingFirstPage, setFetchingFirstPage] = useState<boolean>(true);
|
||||||
@@ -58,7 +53,7 @@ export const ResponsePage = ({
|
|||||||
const fetchNextPage = useCallback(async () => {
|
const fetchNextPage = useCallback(async () => {
|
||||||
const newPage = page + 1;
|
const newPage = page + 1;
|
||||||
|
|
||||||
let newResponses: TResponseWithQuotas[] = [];
|
let newResponses: TResponse[] = [];
|
||||||
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
surveyId,
|
surveyId,
|
||||||
@@ -75,12 +70,14 @@ export const ResponsePage = ({
|
|||||||
setPage(newPage);
|
setPage(newPage);
|
||||||
}, [filters, page, responses, responsesPerPage, surveyId]);
|
}, [filters, page, responses, responsesPerPage, surveyId]);
|
||||||
|
|
||||||
const updateResponseList = (responseIds: string[]) => {
|
const deleteResponses = (responseIds: string[]) => {
|
||||||
setResponses((prev) => prev.filter((r) => !responseIds.includes(r.id)));
|
setResponses(responses.filter((response) => !responseIds.includes(response.id)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateResponse = (responseId: string, updatedResponse: TResponseWithQuotas) => {
|
const updateResponse = (responseId: string, updatedResponse: TResponse) => {
|
||||||
setResponses((prev) => prev.map((r) => (r.id === responseId ? updatedResponse : r)));
|
if (responses) {
|
||||||
|
setResponses(responses.map((response) => (response.id === responseId ? updatedResponse : response)));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const surveyMemoized = useMemo(() => {
|
const surveyMemoized = useMemo(() => {
|
||||||
@@ -97,7 +94,7 @@ export const ResponsePage = ({
|
|||||||
const fetchInitialResponses = async () => {
|
const fetchInitialResponses = async () => {
|
||||||
try {
|
try {
|
||||||
setFetchingFirstPage(true);
|
setFetchingFirstPage(true);
|
||||||
let responses: TResponseWithQuotas[] = [];
|
let responses: TResponse[] = [];
|
||||||
|
|
||||||
const getResponsesActionResponse = await getResponsesAction({
|
const getResponsesActionResponse = await getResponsesAction({
|
||||||
surveyId,
|
surveyId,
|
||||||
@@ -139,12 +136,10 @@ export const ResponsePage = ({
|
|||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
fetchNextPage={fetchNextPage}
|
fetchNextPage={fetchNextPage}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
updateResponseList={updateResponseList}
|
deleteResponses={deleteResponses}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
isFetchingFirstPage={isFetchingFirstPage}
|
isFetchingFirstPage={isFetchingFirstPage}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
|
||||||
quotas={quotas}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-17
@@ -32,8 +32,7 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TSurveyQuota } from "@formbricks/types/quota";
|
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TResponseTableData, TResponseWithQuotas } from "@formbricks/types/responses";
|
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TTag } from "@formbricks/types/tags";
|
import { TTag } from "@formbricks/types/tags";
|
||||||
import { TUser, TUserLocale } from "@formbricks/types/user";
|
import { TUser, TUserLocale } from "@formbricks/types/user";
|
||||||
@@ -41,19 +40,17 @@ import { TUser, TUserLocale } from "@formbricks/types/user";
|
|||||||
interface ResponseTableProps {
|
interface ResponseTableProps {
|
||||||
data: TResponseTableData[];
|
data: TResponseTableData[];
|
||||||
survey: TSurvey;
|
survey: TSurvey;
|
||||||
responses: TResponseWithQuotas[] | null;
|
responses: TResponse[] | null;
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
user?: TUser;
|
user?: TUser;
|
||||||
environmentTags: TTag[];
|
environmentTags: TTag[];
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
fetchNextPage: () => void;
|
fetchNextPage: () => void;
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
updateResponseList: (responseIds: string[]) => void;
|
deleteResponses: (responseIds: string[]) => void;
|
||||||
updateResponse: (responseId: string, updatedResponse: TResponseWithQuotas) => void;
|
updateResponse: (responseId: string, updatedResponse: TResponse) => void;
|
||||||
isFetchingFirstPage: boolean;
|
isFetchingFirstPage: boolean;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isQuotasAllowed: boolean;
|
|
||||||
quotas: TSurveyQuota[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ResponseTable = ({
|
export const ResponseTable = ({
|
||||||
@@ -66,12 +63,10 @@ export const ResponseTable = ({
|
|||||||
isReadOnly,
|
isReadOnly,
|
||||||
fetchNextPage,
|
fetchNextPage,
|
||||||
hasMore,
|
hasMore,
|
||||||
updateResponseList,
|
deleteResponses,
|
||||||
updateResponse,
|
updateResponse,
|
||||||
isFetchingFirstPage,
|
isFetchingFirstPage,
|
||||||
locale,
|
locale,
|
||||||
isQuotasAllowed,
|
|
||||||
quotas,
|
|
||||||
}: ResponseTableProps) => {
|
}: ResponseTableProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
@@ -83,9 +78,8 @@ export const ResponseTable = ({
|
|||||||
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
const [columnOrder, setColumnOrder] = useState<string[]>([]);
|
||||||
const [parent] = useAutoAnimate();
|
const [parent] = useAutoAnimate();
|
||||||
|
|
||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn);
|
const columns = generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t);
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,8 +178,8 @@ export const ResponseTable = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
const deleteResponse = async (responseId: string) => {
|
||||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
await deleteResponseAction({ responseId });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
@@ -227,11 +221,10 @@ export const ResponseTable = ({
|
|||||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||||
isExpanded={isExpanded ?? false}
|
isExpanded={isExpanded ?? false}
|
||||||
table={table}
|
table={table}
|
||||||
updateRowList={updateResponseList}
|
deleteRowsAction={deleteResponses}
|
||||||
type="response"
|
type="response"
|
||||||
deleteAction={deleteResponse}
|
deleteAction={deleteResponse}
|
||||||
downloadRowsAction={downloadSelectedRows}
|
downloadRowsAction={downloadSelectedRows}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
|
||||||
/>
|
/>
|
||||||
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
@@ -306,7 +299,7 @@ export const ResponseTable = ({
|
|||||||
environmentTags={environmentTags}
|
environmentTags={environmentTags}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
updateResponse={updateResponse}
|
updateResponse={updateResponse}
|
||||||
updateResponseList={updateResponseList}
|
deleteResponses={deleteResponses}
|
||||||
setSelectedResponseId={setSelectedResponseId}
|
setSelectedResponseId={setSelectedResponseId}
|
||||||
selectedResponseId={selectedResponseId}
|
selectedResponseId={selectedResponseId}
|
||||||
open={selectedResponse !== null}
|
open={selectedResponse !== null}
|
||||||
|
|||||||
+83
-381
@@ -1,10 +1,12 @@
|
|||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { processResponseData } from "@/lib/responses";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||||
|
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { AnyActionArg } from "react";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseNote, TResponseNoteUser, TResponseTableData } from "@formbricks/types/responses";
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
TSurvey,
|
||||||
TSurveyQuestion,
|
TSurveyQuestion,
|
||||||
@@ -30,6 +32,10 @@ vi.mock("@/lib/i18n/utils", () => ({
|
|||||||
getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default),
|
getLocalizedValue: vi.fn((localizedString, locale) => localizedString[locale] || localizedString.default),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/responses", () => ({
|
||||||
|
processResponseData: vi.fn((data) => (Array.isArray(data) ? data.join(", ") : String(data))),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/utils/contact", () => ({
|
vi.mock("@/lib/utils/contact", () => ({
|
||||||
getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"),
|
getContactIdentifier: vi.fn((person) => person?.attributes?.email || person?.id || "Anonymous"),
|
||||||
}));
|
}));
|
||||||
@@ -54,7 +60,6 @@ vi.mock("@/modules/survey/lib/questions", () => ({
|
|||||||
getQuestionIconMap: vi.fn(() => ({
|
getQuestionIconMap: vi.fn(() => ({
|
||||||
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
|
[TSurveyQuestionTypeEnum.OpenText]: <span>OT</span>,
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
|
[TSurveyQuestionTypeEnum.MultipleChoiceSingle]: <span>MCS</span>,
|
||||||
[TSurveyQuestionTypeEnum.MultipleChoiceMulti]: <span>MCM</span>,
|
|
||||||
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
|
[TSurveyQuestionTypeEnum.Matrix]: <span>MX</span>,
|
||||||
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
|
[TSurveyQuestionTypeEnum.Address]: <span>AD</span>,
|
||||||
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
|
[TSurveyQuestionTypeEnum.ContactInfo]: <span>CI</span>,
|
||||||
@@ -97,33 +102,6 @@ vi.mock("lucide-react", () => ({
|
|||||||
EyeOffIcon: () => <span>EyeOff</span>,
|
EyeOffIcon: () => <span>EyeOff</span>,
|
||||||
MailIcon: () => <span>Mail</span>,
|
MailIcon: () => <span>Mail</span>,
|
||||||
TagIcon: () => <span>Tag</span>,
|
TagIcon: () => <span>Tag</span>,
|
||||||
MousePointerClickIcon: () => <span>MousePointerClick</span>,
|
|
||||||
AirplayIcon: () => <span>Airplay</span>,
|
|
||||||
ArrowUpFromDotIcon: () => <span>ArrowUpFromDot</span>,
|
|
||||||
FlagIcon: () => <span>Flag</span>,
|
|
||||||
GlobeIcon: () => <span>Globe</span>,
|
|
||||||
SmartphoneIcon: () => <span>Smartphone</span>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock new dependencies
|
|
||||||
vi.mock("@/lib/response/utils", () => ({
|
|
||||||
extractChoiceIdsFromResponse: vi.fn((responseValue) => {
|
|
||||||
// Mock implementation that returns choice IDs based on response value
|
|
||||||
if (Array.isArray(responseValue)) {
|
|
||||||
return responseValue.map((_, index) => `choice-${index + 1}`);
|
|
||||||
} else if (typeof responseValue === "string") {
|
|
||||||
return [`choice-single`];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
|
||||||
IdBadge: vi.fn(({ id }) => <div data-testid="id-badge">{id}</div>),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/lib/utils", () => ({
|
|
||||||
cn: vi.fn((...classes) => classes.filter(Boolean).join(" ")),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockSurvey = {
|
const mockSurvey = {
|
||||||
@@ -134,58 +112,30 @@ const mockSurvey = {
|
|||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1open",
|
id: "q1open",
|
||||||
type: "openText",
|
type: TSurveyQuestionTypeEnum.OpenText,
|
||||||
headline: { default: "Open Text Question" },
|
headline: { default: "Open Text Question" },
|
||||||
required: true,
|
required: true,
|
||||||
} as unknown as TSurveyQuestion,
|
} as unknown as TSurveyQuestion,
|
||||||
{
|
{
|
||||||
id: "q2matrix",
|
id: "q2matrix",
|
||||||
type: "matrix",
|
type: TSurveyQuestionTypeEnum.Matrix,
|
||||||
headline: { default: "Matrix Question" },
|
headline: { default: "Matrix Question" },
|
||||||
rows: [
|
rows: [{ default: "Row1" }, { default: "Row2" }],
|
||||||
{ id: "row-1", label: { default: "Row1" } },
|
columns: [{ default: "Col1" }, { default: "Col2" }],
|
||||||
{ id: "row-2", label: { default: "Row2" } },
|
|
||||||
],
|
|
||||||
columns: [
|
|
||||||
{ id: "col-1", label: { default: "Col1" } },
|
|
||||||
{ id: "col-2", label: { default: "Col2" } },
|
|
||||||
],
|
|
||||||
required: false,
|
required: false,
|
||||||
} as unknown as TSurveyQuestion,
|
} as unknown as TSurveyQuestion,
|
||||||
{
|
{
|
||||||
id: "q3address",
|
id: "q3address",
|
||||||
type: "address",
|
type: TSurveyQuestionTypeEnum.Address,
|
||||||
headline: { default: "Address Question" },
|
headline: { default: "Address Question" },
|
||||||
required: false,
|
required: false,
|
||||||
} as unknown as TSurveyQuestion,
|
} as unknown as TSurveyQuestion,
|
||||||
{
|
{
|
||||||
id: "q4contact",
|
id: "q4contact",
|
||||||
type: "contactInfo",
|
type: TSurveyQuestionTypeEnum.ContactInfo,
|
||||||
headline: { default: "Contact Info Question" },
|
headline: { default: "Contact Info Question" },
|
||||||
required: false,
|
required: false,
|
||||||
} as unknown as TSurveyQuestion,
|
} as unknown as TSurveyQuestion,
|
||||||
{
|
|
||||||
id: "q5single",
|
|
||||||
type: "multipleChoiceSingle",
|
|
||||||
headline: { default: "Single Choice Question" },
|
|
||||||
required: false,
|
|
||||||
choices: [
|
|
||||||
{ id: "choice-1", label: { default: "Option 1" } },
|
|
||||||
{ id: "choice-2", label: { default: "Option 2" } },
|
|
||||||
{ id: "choice-3", label: { default: "Option 3" } },
|
|
||||||
],
|
|
||||||
} as unknown as TSurveyQuestion,
|
|
||||||
{
|
|
||||||
id: "q6multi",
|
|
||||||
type: "multipleChoiceMulti",
|
|
||||||
headline: { default: "Multi Choice Question" },
|
|
||||||
required: false,
|
|
||||||
choices: [
|
|
||||||
{ id: "choice-a", label: { default: "Choice A" } },
|
|
||||||
{ id: "choice-b", label: { default: "Choice B" } },
|
|
||||||
{ id: "choice-c", label: { default: "Choice C" } },
|
|
||||||
],
|
|
||||||
} as unknown as TSurveyQuestion,
|
|
||||||
],
|
],
|
||||||
variables: [
|
variables: [
|
||||||
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
|
{ id: "var1", name: "User Segment", type: "text" } as TSurveyVariable,
|
||||||
@@ -223,13 +173,19 @@ const mockResponseData = {
|
|||||||
firstName: "John",
|
firstName: "John",
|
||||||
email: "john.doe@example.com",
|
email: "john.doe@example.com",
|
||||||
hf1: "Hidden Field 1 Value",
|
hf1: "Hidden Field 1 Value",
|
||||||
q5single: "Option 1", // Single choice response
|
|
||||||
q6multi: ["Choice A", "Choice C"], // Multi choice response
|
|
||||||
},
|
},
|
||||||
variables: {
|
variables: {
|
||||||
var1: "Segment A",
|
var1: "Segment A",
|
||||||
var2: 100,
|
var2: 100,
|
||||||
},
|
},
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
id: "note1",
|
||||||
|
text: "This is a note",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
user: { name: "User" } as unknown as TResponseNoteUser,
|
||||||
|
} as TResponseNote,
|
||||||
|
],
|
||||||
status: "completed",
|
status: "completed",
|
||||||
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
|
tags: [{ id: "tag1", name: "Important" } as unknown as TTag],
|
||||||
language: "default",
|
language: "default",
|
||||||
@@ -246,44 +202,44 @@ describe("generateResponseTableColumns", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("should include selection column when not read-only", () => {
|
test("should include selection column when not read-only", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, false, t as any);
|
||||||
expect(columns[0].id).toBe("select");
|
expect(columns[0].id).toBe("select");
|
||||||
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
|
expect(vi.mocked(getSelectionColumn)).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not include selection column when read-only", () => {
|
test("should not include selection column when read-only", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
expect(columns[0].id).not.toBe("select");
|
expect(columns[0].id).not.toBe("select");
|
||||||
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
|
expect(vi.mocked(getSelectionColumn)).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
|
test("should include Verified Email column when survey.isVerifyEmailEnabled is true", () => {
|
||||||
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
|
const surveyWithVerifiedEmail = { ...mockSurvey, isVerifyEmailEnabled: true };
|
||||||
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any, false);
|
const columns = generateResponseTableColumns(surveyWithVerifiedEmail, false, true, t as any);
|
||||||
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
|
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
|
test("should not include Verified Email column when survey.isVerifyEmailEnabled is false", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
|
expect(columns.some((col) => (col as any).accessorKey === "verifiedEmail")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should generate columns for variables", () => {
|
test("should generate columns for variables", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const var1Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
|
const var1Col = columns.find((col) => (col as any).accessorKey === "var1");
|
||||||
expect(var1Col).toBeDefined();
|
expect(var1Col).toBeDefined();
|
||||||
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
const var1Cell = (var1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||||
expect(var1Cell.props.children).toBe("Segment A");
|
expect(var1Cell.props.children).toBe("Segment A");
|
||||||
|
|
||||||
const var2Col = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
|
const var2Col = columns.find((col) => (col as any).accessorKey === "var2");
|
||||||
expect(var2Col).toBeDefined();
|
expect(var2Col).toBeDefined();
|
||||||
const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
const var2Cell = (var2Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||||
expect(var2Cell.props.children).toBe(100);
|
expect(var2Cell.props.children).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should generate columns for hidden fields if fieldIds exist", () => {
|
test("should generate columns for hidden fields if fieldIds exist", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||||
expect(hf1Col).toBeDefined();
|
expect(hf1Col).toBeDefined();
|
||||||
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
const hf1Cell = (hf1Col?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||||
expect(hf1Cell.props.children).toBe("Hidden Field 1 Value");
|
expect(hf1Cell.props.children).toBe("Hidden Field 1 Value");
|
||||||
@@ -291,10 +247,18 @@ describe("generateResponseTableColumns", () => {
|
|||||||
|
|
||||||
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
|
test("should not generate columns for hidden fields if fieldIds is undefined", () => {
|
||||||
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
|
const surveyWithoutHiddenFieldIds = { ...mockSurvey, hiddenFields: { enabled: true } };
|
||||||
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any, false);
|
const columns = generateResponseTableColumns(surveyWithoutHiddenFieldIds, false, true, t as any);
|
||||||
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
const hf1Col = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||||
expect(hf1Col).toBeUndefined();
|
expect(hf1Col).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should generate Notes column", () => {
|
||||||
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
|
const notesCol = columns.find((col) => (col as any).accessorKey === "notes");
|
||||||
|
expect(notesCol).toBeDefined();
|
||||||
|
(notesCol?.cell as any)?.({ row: { original: mockResponseData } } as any);
|
||||||
|
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["This is a note"]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ResponseTableColumns", () => {
|
describe("ResponseTableColumns", () => {
|
||||||
@@ -316,7 +280,7 @@ describe("ResponseTableColumns", () => {
|
|||||||
const isReadOnly = false;
|
const isReadOnly = false;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
|
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
const verifiedEmailColumn: any = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||||
@@ -344,7 +308,7 @@ describe("ResponseTableColumns", () => {
|
|||||||
const isReadOnly = false;
|
const isReadOnly = false;
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT, false);
|
const columns = generateResponseTableColumns(mockSurvey, isExpanded, isReadOnly, mockT);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
const verifiedEmailColumn = columns.find((col: any) => col.accessorKey === "verifiedEmail");
|
||||||
@@ -358,7 +322,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("dateColumn renders with formatted date", () => {
|
test("dateColumn renders with formatted date", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
|
const dateColumn: any = columns.find((col) => (col as any).accessorKey === "createdAt");
|
||||||
expect(dateColumn).toBeDefined();
|
expect(dateColumn).toBeDefined();
|
||||||
|
|
||||||
@@ -376,7 +340,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("personColumn renders anonymous when person is null", () => {
|
test("personColumn renders anonymous when person is null", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||||
expect(personColumn).toBeDefined();
|
expect(personColumn).toBeDefined();
|
||||||
|
|
||||||
@@ -399,7 +363,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("personColumn renders person identifier when person exists", () => {
|
test("personColumn renders person identifier when person exists", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
const personColumn: any = columns.find((col) => (col as any).accessorKey === "personId");
|
||||||
expect(personColumn).toBeDefined();
|
expect(personColumn).toBeDefined();
|
||||||
|
|
||||||
@@ -420,7 +384,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tagsColumn returns undefined when tags is not an array", () => {
|
test("tagsColumn returns undefined when tags is not an array", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
|
const tagsColumn: any = columns.find((col) => (col as any).accessorKey === "tags");
|
||||||
expect(tagsColumn).toBeDefined();
|
expect(tagsColumn).toBeDefined();
|
||||||
|
|
||||||
@@ -434,11 +398,41 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
expect(cellResult).toBeUndefined();
|
expect(cellResult).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("notesColumn renders when notes is an array", () => {
|
||||||
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
|
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||||
|
expect(notesColumn).toBeDefined();
|
||||||
|
|
||||||
|
// Mock a response with notes
|
||||||
|
const mockRow = {
|
||||||
|
original: { notes: [{ text: "Note 1" }, { text: "Note 2" }] },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Call the cell function
|
||||||
|
notesColumn?.cell?.({ row: mockRow } as any);
|
||||||
|
expect(vi.mocked(processResponseData)).toHaveBeenCalledWith(["Note 1", "Note 2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notesColumn returns undefined when notes is not an array", () => {
|
||||||
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
|
const notesColumn: any = columns.find((col) => (col as any).accessorKey === "notes");
|
||||||
|
expect(notesColumn).toBeDefined();
|
||||||
|
|
||||||
|
// Mock a response with no notes
|
||||||
|
const mockRow = {
|
||||||
|
original: { notes: null },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
// Call the cell function
|
||||||
|
const cellResult = notesColumn?.cell?.({ row: mockRow } as any);
|
||||||
|
expect(cellResult).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test("variableColumns render variable values correctly", () => {
|
test("variableColumns render variable values correctly", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
|
|
||||||
// Find the variable column for var1
|
// Find the variable column for var1
|
||||||
const var1Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var1");
|
const var1Column: any = columns.find((col) => (col as any).accessorKey === "var1");
|
||||||
expect(var1Column).toBeDefined();
|
expect(var1Column).toBeDefined();
|
||||||
|
|
||||||
// Test the header
|
// Test the header
|
||||||
@@ -455,7 +449,7 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
expect(cellResult?.props.children).toBe("Test Value");
|
expect(cellResult?.props.children).toBe("Test Value");
|
||||||
|
|
||||||
// Test with a number variable
|
// Test with a number variable
|
||||||
const var2Column: any = columns.find((col) => (col as any).accessorKey === "VARIABLE_var2");
|
const var2Column: any = columns.find((col) => (col as any).accessorKey === "var2");
|
||||||
expect(var2Column).toBeDefined();
|
expect(var2Column).toBeDefined();
|
||||||
|
|
||||||
const mockRowNumber = {
|
const mockRowNumber = {
|
||||||
@@ -467,10 +461,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("hiddenFieldColumns render when fieldIds exist", () => {
|
test("hiddenFieldColumns render when fieldIds exist", () => {
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any);
|
||||||
|
|
||||||
// Find the hidden field column
|
// Find the hidden field column
|
||||||
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
const hfColumn: any = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||||
expect(hfColumn).toBeDefined();
|
expect(hfColumn).toBeDefined();
|
||||||
|
|
||||||
// Test the header
|
// Test the header
|
||||||
@@ -494,302 +488,10 @@ describe("ResponseTableColumns - Column Implementations", () => {
|
|||||||
hiddenFields: { enabled: true }, // no fieldIds
|
hiddenFields: { enabled: true }, // no fieldIds
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any, false);
|
const columns = generateResponseTableColumns(surveyWithNoHiddenFields, false, true, t as any);
|
||||||
|
|
||||||
// Check that no hidden field columns were created
|
// Check that no hidden field columns were created
|
||||||
const hfColumn = columns.find((col) => (col as any).accessorKey === "HIDDEN_FIELD_hf1");
|
const hfColumn = columns.find((col) => (col as any).accessorKey === "hf1");
|
||||||
expect(hfColumn).toBeUndefined();
|
expect(hfColumn).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ResponseTableColumns - Multiple Choice Questions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("generates two columns for multipleChoiceSingle questions", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
|
|
||||||
// Should have main response column
|
|
||||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
|
||||||
expect(mainColumn).toBeDefined();
|
|
||||||
|
|
||||||
// Should have option IDs column
|
|
||||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
|
|
||||||
expect(optionIdsColumn).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("generates two columns for multipleChoiceMulti questions", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
|
|
||||||
// Should have main response column
|
|
||||||
const mainColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
|
||||||
expect(mainColumn).toBeDefined();
|
|
||||||
|
|
||||||
// Should have option IDs column
|
|
||||||
const optionIdsColumn = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
|
|
||||||
expect(optionIdsColumn).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multipleChoiceSingle main column renders RenderResponse component", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q5single: "Option 1" },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
// Check that RenderResponse component is returned
|
|
||||||
expect(cellResult).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multipleChoiceMulti main column renders RenderResponse component", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cellResult = mainColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
// Check that RenderResponse component is returned
|
|
||||||
expect(cellResult).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ResponseTableColumns - Choice ID Columns", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column calls extractChoiceIdsFromResponse for string response", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q5single: "Option 1" },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
|
||||||
"Option 1",
|
|
||||||
expect.objectContaining({ id: "q5single", type: "multipleChoiceSingle" }),
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column calls extractChoiceIdsFromResponse for array response", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
|
||||||
["Choice A", "Choice C"],
|
|
||||||
expect.objectContaining({ id: "q6multi", type: "multipleChoiceMulti" }),
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column renders IdBadge components for choice IDs", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q6multioptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q6multi: ["Choice A", "Choice C"] },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock extractChoiceIdsFromResponse to return specific choice IDs
|
|
||||||
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce(["choice-1", "choice-3"]);
|
|
||||||
|
|
||||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
// Should render something for choice IDs
|
|
||||||
expect(cellResult).toBeDefined();
|
|
||||||
// Verify that extractChoiceIdsFromResponse was called
|
|
||||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column returns null for non-string/array response values", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q5single: 123 }, // Invalid type
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
expect(cellResult).toBeNull();
|
|
||||||
expect(vi.mocked(extractChoiceIdsFromResponse)).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column returns null when no choice IDs found", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q5single: "Non-existent option" },
|
|
||||||
language: "default",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock extractChoiceIdsFromResponse to return empty array
|
|
||||||
vi.mocked(extractChoiceIdsFromResponse).mockReturnValueOnce([]);
|
|
||||||
|
|
||||||
const cellResult = optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
expect(cellResult).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("option IDs column handles missing language gracefully", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
const mockRow = {
|
|
||||||
original: {
|
|
||||||
responseData: { q5single: "Option 1" },
|
|
||||||
language: null, // No language
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
optionIdsColumn?.cell?.({ row: mockRow } as any);
|
|
||||||
|
|
||||||
expect(vi.mocked(extractChoiceIdsFromResponse)).toHaveBeenCalledWith(
|
|
||||||
"Option 1",
|
|
||||||
expect.objectContaining({ id: "q5single" }),
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ResponseTableColumns - Helper Functions", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("question headers are properly created for multiple choice questions", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const mainColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
|
||||||
const optionIdsColumn: any = columns.find(
|
|
||||||
(col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test main column header
|
|
||||||
const mainHeader = mainColumn?.header?.();
|
|
||||||
expect(mainHeader).toBeDefined();
|
|
||||||
expect(mainHeader?.props?.className).toContain("flex items-center justify-between");
|
|
||||||
|
|
||||||
// Test option IDs column header
|
|
||||||
const optionHeader = optionIdsColumn?.header?.();
|
|
||||||
expect(optionHeader).toBeDefined();
|
|
||||||
expect(optionHeader?.props?.className).toContain("flex items-center justify-between");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("question headers include proper icons for multiple choice questions", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
const singleChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
|
||||||
const multiChoiceColumn: any = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
|
||||||
|
|
||||||
// Headers should be functions that return JSX
|
|
||||||
expect(typeof singleChoiceColumn?.header).toBe("function");
|
|
||||||
expect(typeof multiChoiceColumn?.header).toBe("function");
|
|
||||||
|
|
||||||
// Call headers to ensure they don't throw
|
|
||||||
expect(() => singleChoiceColumn?.header?.()).not.toThrow();
|
|
||||||
expect(() => multiChoiceColumn?.header?.()).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ResponseTableColumns - Integration Tests", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("multiple choice questions work end-to-end with real data", () => {
|
|
||||||
const columns = generateResponseTableColumns(mockSurvey, false, true, t as any, false);
|
|
||||||
|
|
||||||
// Find all multiple choice related columns
|
|
||||||
const singleMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5single");
|
|
||||||
const singleIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q5singleoptionIds");
|
|
||||||
const multiMainCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multi");
|
|
||||||
const multiIdsCol = columns.find((col) => (col as any).accessorKey === "QUESTION_q6multioptionIds");
|
|
||||||
|
|
||||||
expect(singleMainCol).toBeDefined();
|
|
||||||
expect(singleIdsCol).toBeDefined();
|
|
||||||
expect(multiMainCol).toBeDefined();
|
|
||||||
expect(multiIdsCol).toBeDefined();
|
|
||||||
|
|
||||||
// Test with actual mock response data
|
|
||||||
const mockRow = { original: mockResponseData };
|
|
||||||
|
|
||||||
// Test single choice main column
|
|
||||||
const singleMainResult = (singleMainCol?.cell as any)?.({ row: mockRow });
|
|
||||||
expect(singleMainResult).toBeDefined();
|
|
||||||
|
|
||||||
// Test multi choice main column
|
|
||||||
const multiMainResult = (multiMainCol?.cell as any)?.({ row: mockRow });
|
|
||||||
expect(multiMainResult).toBeDefined();
|
|
||||||
|
|
||||||
// Test that choice ID columns exist and can be called
|
|
||||||
const singleIdsResult = (singleIdsCol?.cell as any)?.({ row: mockRow });
|
|
||||||
const multiIdsResult = (multiIdsCol?.cell as any)?.({ row: mockRow });
|
|
||||||
|
|
||||||
// Should not error when calling the cell functions
|
|
||||||
expect(() => singleIdsResult).not.toThrow();
|
|
||||||
expect(() => multiIdsResult).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+70
-155
@@ -1,31 +1,58 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { processResponseData } from "@/lib/responses";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
import { RenderResponse } from "@/modules/analysis/components/SingleResponseCard/components/RenderResponse";
|
||||||
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
import { VARIABLES_ICON_MAP, getQuestionIconMap } from "@/modules/survey/lib/questions";
|
||||||
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
import { getSelectionColumn } from "@/modules/ui/components/data-table";
|
||||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
|
||||||
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
import { ResponseBadges } from "@/modules/ui/components/response-badges";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { cn } from "@/modules/ui/lib/utils";
|
|
||||||
import { ColumnDef } from "@tanstack/react-table";
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { TFnType } from "@tolgee/react";
|
import { TFnType } from "@tolgee/react";
|
||||||
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
import { CircleHelpIcon, EyeOffIcon, MailIcon, TagIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TResponseTableData } from "@formbricks/types/responses";
|
import { TResponseTableData } from "@formbricks/types/responses";
|
||||||
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestion } from "@formbricks/types/surveys/types";
|
||||||
import {
|
|
||||||
COLUMNS_ICON_MAP,
|
const getAddressFieldLabel = (field: string, t: TFnType) => {
|
||||||
METADATA_FIELDS,
|
switch (field) {
|
||||||
getAddressFieldLabel,
|
case "addressLine1":
|
||||||
getContactInfoFieldLabel,
|
return t("environments.surveys.responses.address_line_1");
|
||||||
getMetadataFieldLabel,
|
case "addressLine2":
|
||||||
getMetadataValue,
|
return t("environments.surveys.responses.address_line_2");
|
||||||
} from "../lib/utils";
|
case "city":
|
||||||
|
return t("environments.surveys.responses.city");
|
||||||
|
case "state":
|
||||||
|
return t("environments.surveys.responses.state_region");
|
||||||
|
case "zip":
|
||||||
|
return t("environments.surveys.responses.zip_post_code");
|
||||||
|
case "country":
|
||||||
|
return t("environments.surveys.responses.country");
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
||||||
|
switch (field) {
|
||||||
|
case "firstName":
|
||||||
|
return t("environments.surveys.responses.first_name");
|
||||||
|
case "lastName":
|
||||||
|
return t("environments.surveys.responses.last_name");
|
||||||
|
case "email":
|
||||||
|
return t("environments.surveys.responses.email");
|
||||||
|
case "phone":
|
||||||
|
return t("environments.surveys.responses.phone");
|
||||||
|
case "company":
|
||||||
|
return t("environments.surveys.responses.company");
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getQuestionColumnsData = (
|
const getQuestionColumnsData = (
|
||||||
question: TSurveyQuestion,
|
question: TSurveyQuestion,
|
||||||
@@ -34,49 +61,11 @@ const getQuestionColumnsData = (
|
|||||||
t: TFnType
|
t: TFnType
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
const QUESTIONS_ICON_MAP = getQuestionIconMap(t);
|
||||||
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
|
||||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
|
||||||
|
|
||||||
// Helper function to create consistent column headers
|
|
||||||
const createQuestionHeader = (questionType: string, headline: string, suffix?: string) => {
|
|
||||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
|
||||||
const QuestionHeader = () => (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
|
||||||
<span className="h-4 w-4">{QUESTIONS_ICON_MAP[questionType]}</span>
|
|
||||||
<span className="truncate">{title}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
QuestionHeader.displayName = "QuestionHeader";
|
|
||||||
return QuestionHeader;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get localized question headline
|
|
||||||
const getQuestionHeadline = (question: TSurveyQuestion, survey: TSurvey) => {
|
|
||||||
return getLocalizedValue(recallToHeadline(question.headline, survey, false, "default"), "default");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to render choice ID badges
|
|
||||||
const renderChoiceIdBadges = (choiceIds: string[], isExpanded: boolean) => {
|
|
||||||
if (choiceIds.length === 0) return null;
|
|
||||||
|
|
||||||
const containerClasses = cn("flex gap-x-1 w-full", isExpanded && "flex-wrap gap-y-1");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={containerClasses}>
|
|
||||||
{choiceIds.map((choiceId, index) => (
|
|
||||||
<IdBadge key={`${choiceId}-${index}`} id={choiceId} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (question.type) {
|
switch (question.type) {
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return question.rows.map((matrixRow) => {
|
return question.rows.map((matrixRow) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "QUESTION_" + question.id + "_" + matrixRow.label.default,
|
accessorKey: matrixRow.default,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -85,14 +74,14 @@ const getQuestionColumnsData = (
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{getLocalizedValue(question.headline, "default") +
|
{getLocalizedValue(question.headline, "default") +
|
||||||
" - " +
|
" - " +
|
||||||
getLocalizedValue(matrixRow.label, "default")}
|
getLocalizedValue(matrixRow, "default")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const responseValue = row.original.responseData[matrixRow.label.default];
|
const responseValue = row.original.responseData[matrixRow.default];
|
||||||
if (typeof responseValue === "string") {
|
if (typeof responseValue === "string") {
|
||||||
return <p className="text-slate-900">{responseValue}</p>;
|
return <p className="text-slate-900">{responseValue}</p>;
|
||||||
}
|
}
|
||||||
@@ -101,9 +90,10 @@ const getQuestionColumnsData = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
case "address":
|
case "address":
|
||||||
|
const addressFields = ["addressLine1", "addressLine2", "city", "state", "zip", "country"];
|
||||||
return addressFields.map((addressField) => {
|
return addressFields.map((addressField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "QUESTION_" + question.id + "_" + addressField,
|
accessorKey: addressField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -124,9 +114,10 @@ const getQuestionColumnsData = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
case "contactInfo":
|
case "contactInfo":
|
||||||
|
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
return contactInfoFields.map((contactInfoField) => {
|
return contactInfoFields.map((contactInfoField) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "QUESTION_" + question.id + "_" + contactInfoField,
|
accessorKey: contactInfoField,
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -146,54 +137,10 @@ const getQuestionColumnsData = (
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
case "multipleChoiceMulti":
|
|
||||||
case "multipleChoiceSingle":
|
|
||||||
case "ranking":
|
|
||||||
case "pictureSelection": {
|
|
||||||
const questionHeadline = getQuestionHeadline(question, survey);
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: "QUESTION_" + question.id,
|
|
||||||
header: createQuestionHeader(question.type, questionHeadline),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const responseValue = row.original.responseData[question.id];
|
|
||||||
const language = row.original.language;
|
|
||||||
return (
|
|
||||||
<RenderResponse
|
|
||||||
question={question}
|
|
||||||
survey={survey}
|
|
||||||
responseData={responseValue}
|
|
||||||
language={language}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
showId={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "QUESTION_" + question.id + "optionIds",
|
|
||||||
header: createQuestionHeader(question.type, questionHeadline, t("common.option_id")),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const responseValue = row.original.responseData[question.id];
|
|
||||||
// Type guard to ensure responseValue is the correct type
|
|
||||||
if (typeof responseValue === "string" || Array.isArray(responseValue)) {
|
|
||||||
const choiceIds = extractChoiceIdsFromResponse(
|
|
||||||
responseValue,
|
|
||||||
question,
|
|
||||||
row.original.language || undefined
|
|
||||||
);
|
|
||||||
return renderChoiceIdBadges(choiceIds, isExpanded);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
accessorKey: "QUESTION_" + question.id,
|
accessorKey: question.id,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
@@ -217,7 +164,6 @@ const getQuestionColumnsData = (
|
|||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -226,39 +172,11 @@ const getQuestionColumnsData = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMetadataColumnsData = (t: TFnType): ColumnDef<TResponseTableData>[] => {
|
|
||||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
|
||||||
|
|
||||||
METADATA_FIELDS.forEach((label) => {
|
|
||||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
|
||||||
|
|
||||||
metadataColumns.push({
|
|
||||||
accessorKey: "METADATA_" + label,
|
|
||||||
header: () => (
|
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
|
||||||
<span className="h-4 w-4">{IconComponent && <IconComponent className="h-4 w-4" />}</span>
|
|
||||||
<span className="truncate">{getMetadataFieldLabel(label, t)}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const value = getMetadataValue(row.original.meta, label);
|
|
||||||
if (value) {
|
|
||||||
return <div className="truncate text-slate-900">{value}</div>;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return metadataColumns;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const generateResponseTableColumns = (
|
export const generateResponseTableColumns = (
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isReadOnly: boolean,
|
isReadOnly: boolean,
|
||||||
t: TFnType,
|
t: TFnType
|
||||||
showQuotasColumn: boolean
|
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const questionColumns = survey.questions.flatMap((question) =>
|
const questionColumns = survey.questions.flatMap((question) =>
|
||||||
getQuestionColumnsData(question, survey, isExpanded, t)
|
getQuestionColumnsData(question, survey, isExpanded, t)
|
||||||
@@ -284,13 +202,12 @@ export const generateResponseTableColumns = (
|
|||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
|
<CircleHelpIcon className="h-3 w-3 text-slate-500" strokeWidth={1.5} />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" className="space-x-1 font-normal">
|
<TooltipContent side="bottom" className="font-normal">
|
||||||
<span>{t("environments.surveys.responses.how_to_identify_users")}</span>
|
{t("environments.surveys.responses.how_to_identify_users")}
|
||||||
<Link
|
<Link
|
||||||
className="underline underline-offset-2 hover:text-slate-900"
|
className="underline underline-offset-2 hover:text-slate-900"
|
||||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||||
target="_blank"
|
target="_blank">
|
||||||
rel="noopener noreferrer">
|
|
||||||
{t("common.app_survey")}
|
{t("common.app_survey")}
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -307,49 +224,49 @@ export const generateResponseTableColumns = (
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const quotasColumn: ColumnDef<TResponseTableData> = {
|
|
||||||
accessorKey: "quota",
|
|
||||||
header: t("common.quota"),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const quotas = row.original.quotas;
|
|
||||||
const items = quotas?.map((quota) => ({ value: quota })) ?? [];
|
|
||||||
return <ResponseBadges items={items} showId={false} />;
|
|
||||||
},
|
|
||||||
size: 200,
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColumn: ColumnDef<TResponseTableData> = {
|
const statusColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
size: 200,
|
size: 200,
|
||||||
header: () => <div className="gap-x-1.5">{t("common.status")}</div>,
|
header: t("common.status"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.status;
|
const status = row.original.status;
|
||||||
return <ResponseBadges items={[{ value: status }]} showId={false} />;
|
return <ResponseBadges items={[status]} />;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagsColumn: ColumnDef<TResponseTableData> = {
|
const tagsColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "tags",
|
accessorKey: "tags",
|
||||||
header: () => <div className="gap-x-1.5">{t("common.tags")}</div>,
|
header: t("common.tags"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const tags = row.original.tags;
|
const tags = row.original.tags;
|
||||||
if (Array.isArray(tags)) {
|
if (Array.isArray(tags)) {
|
||||||
const tagsArray = tags.map((tag) => tag.name);
|
const tagsArray = tags.map((tag) => tag.name);
|
||||||
return (
|
return (
|
||||||
<ResponseBadges
|
<ResponseBadges
|
||||||
items={tagsArray.map((tag) => ({ value: tag }))}
|
items={tagsArray}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
|
icon={<TagIcon className="h-4 w-4 text-slate-500" />}
|
||||||
showId={false}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const notesColumn: ColumnDef<TResponseTableData> = {
|
||||||
|
accessorKey: "notes",
|
||||||
|
header: t("common.notes"),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const notes = row.original.notes;
|
||||||
|
if (Array.isArray(notes)) {
|
||||||
|
const notesArray = notes.map((note) => note.text);
|
||||||
|
return processResponseData(notesArray);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
const variableColumns: ColumnDef<TResponseTableData>[] = survey.variables.map((variable) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "VARIABLE_" + variable.id,
|
accessorKey: variable.id,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
<span className="h-4 w-4">{VARIABLES_ICON_MAP[variable.type]}</span>
|
||||||
@@ -368,7 +285,7 @@ export const generateResponseTableColumns = (
|
|||||||
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
const hiddenFieldColumns: ColumnDef<TResponseTableData>[] = survey.hiddenFields.fieldIds
|
||||||
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
? survey.hiddenFields.fieldIds.map((hiddenFieldId) => {
|
||||||
return {
|
return {
|
||||||
accessorKey: "HIDDEN_FIELD_" + hiddenFieldId,
|
accessorKey: hiddenFieldId,
|
||||||
header: () => (
|
header: () => (
|
||||||
<div className="flex items-center space-x-2 overflow-hidden">
|
<div className="flex items-center space-x-2 overflow-hidden">
|
||||||
<span className="h-4 w-4">
|
<span className="h-4 w-4">
|
||||||
@@ -387,8 +304,6 @@ export const generateResponseTableColumns = (
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const metadataColumns = getMetadataColumnsData(t);
|
|
||||||
|
|
||||||
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
|
const verifiedEmailColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "verifiedEmail",
|
accessorKey: "verifiedEmail",
|
||||||
header: () => (
|
header: () => (
|
||||||
@@ -402,17 +317,17 @@ export const generateResponseTableColumns = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Combine the selection column with the dynamic question columns
|
// Combine the selection column with the dynamic question columns
|
||||||
|
|
||||||
const baseColumns = [
|
const baseColumns = [
|
||||||
personColumn,
|
personColumn,
|
||||||
dateColumn,
|
dateColumn,
|
||||||
...(showQuotasColumn ? [quotasColumn] : []),
|
|
||||||
statusColumn,
|
statusColumn,
|
||||||
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
...(survey.isVerifyEmailEnabled ? [verifiedEmailColumn] : []),
|
||||||
...questionColumns,
|
...questionColumns,
|
||||||
...variableColumns,
|
...variableColumns,
|
||||||
...hiddenFieldColumns,
|
...hiddenFieldColumns,
|
||||||
...metadataColumns,
|
|
||||||
tagsColumn,
|
tagsColumn,
|
||||||
|
notesColumn,
|
||||||
];
|
];
|
||||||
|
|
||||||
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
|
||||||
|
|||||||
-204
@@ -1,204 +0,0 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
|
||||||
import {
|
|
||||||
AirplayIcon,
|
|
||||||
ArrowUpFromDotIcon,
|
|
||||||
FlagIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
MousePointerClickIcon,
|
|
||||||
SmartphoneIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
COLUMNS_ICON_MAP,
|
|
||||||
getAddressFieldLabel,
|
|
||||||
getContactInfoFieldLabel,
|
|
||||||
getMetadataFieldLabel,
|
|
||||||
getMetadataValue,
|
|
||||||
} from "./utils";
|
|
||||||
|
|
||||||
describe("utils", () => {
|
|
||||||
const mockT = vi.fn((key: string) => {
|
|
||||||
const translations: Record<string, string> = {
|
|
||||||
"environments.surveys.responses.address_line_1": "Address Line 1",
|
|
||||||
"environments.surveys.responses.address_line_2": "Address Line 2",
|
|
||||||
"environments.surveys.responses.city": "City",
|
|
||||||
"environments.surveys.responses.state_region": "State/Region",
|
|
||||||
"environments.surveys.responses.zip_post_code": "ZIP/Post Code",
|
|
||||||
"environments.surveys.responses.country": "Country",
|
|
||||||
"environments.surveys.responses.first_name": "First Name",
|
|
||||||
"environments.surveys.responses.last_name": "Last Name",
|
|
||||||
"environments.surveys.responses.email": "Email",
|
|
||||||
"environments.surveys.responses.phone": "Phone",
|
|
||||||
"environments.surveys.responses.company": "Company",
|
|
||||||
"common.action": "Action",
|
|
||||||
"environments.surveys.responses.os": "OS",
|
|
||||||
"environments.surveys.responses.device": "Device",
|
|
||||||
"environments.surveys.responses.browser": "Browser",
|
|
||||||
"common.url": "URL",
|
|
||||||
"environments.surveys.responses.source": "Source",
|
|
||||||
};
|
|
||||||
return translations[key] || key;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAddressFieldLabel", () => {
|
|
||||||
test("returns correct label for addressLine1", () => {
|
|
||||||
const result = getAddressFieldLabel("addressLine1", mockT);
|
|
||||||
expect(result).toBe("Address Line 1");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_1");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for addressLine2", () => {
|
|
||||||
const result = getAddressFieldLabel("addressLine2", mockT);
|
|
||||||
expect(result).toBe("Address Line 2");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.address_line_2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for city", () => {
|
|
||||||
const result = getAddressFieldLabel("city", mockT);
|
|
||||||
expect(result).toBe("City");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.city");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for state", () => {
|
|
||||||
const result = getAddressFieldLabel("state", mockT);
|
|
||||||
expect(result).toBe("State/Region");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.state_region");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for zip", () => {
|
|
||||||
const result = getAddressFieldLabel("zip", mockT);
|
|
||||||
expect(result).toBe("ZIP/Post Code");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.zip_post_code");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for country", () => {
|
|
||||||
const result = getAddressFieldLabel("country", mockT);
|
|
||||||
expect(result).toBe("Country");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined for unknown field", () => {
|
|
||||||
const result = getAddressFieldLabel("unknown", mockT);
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(mockT).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getContactInfoFieldLabel", () => {
|
|
||||||
test("returns correct label for firstName", () => {
|
|
||||||
const result = getContactInfoFieldLabel("firstName", mockT);
|
|
||||||
expect(result).toBe("First Name");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.first_name");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for lastName", () => {
|
|
||||||
const result = getContactInfoFieldLabel("lastName", mockT);
|
|
||||||
expect(result).toBe("Last Name");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.last_name");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for email", () => {
|
|
||||||
const result = getContactInfoFieldLabel("email", mockT);
|
|
||||||
expect(result).toBe("Email");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.email");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for phone", () => {
|
|
||||||
const result = getContactInfoFieldLabel("phone", mockT);
|
|
||||||
expect(result).toBe("Phone");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.phone");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for company", () => {
|
|
||||||
const result = getContactInfoFieldLabel("company", mockT);
|
|
||||||
expect(result).toBe("Company");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.company");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns undefined for unknown field", () => {
|
|
||||||
const result = getContactInfoFieldLabel("unknown", mockT);
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(mockT).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getMetadataFieldLabel", () => {
|
|
||||||
test("returns correct label for action", () => {
|
|
||||||
const result = getMetadataFieldLabel("action", mockT);
|
|
||||||
expect(result).toBe("Action");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("common.action");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for country", () => {
|
|
||||||
const result = getMetadataFieldLabel("country", mockT);
|
|
||||||
expect(result).toBe("Country");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.country");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for os", () => {
|
|
||||||
const result = getMetadataFieldLabel("os", mockT);
|
|
||||||
expect(result).toBe("OS");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.os");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for device", () => {
|
|
||||||
const result = getMetadataFieldLabel("device", mockT);
|
|
||||||
expect(result).toBe("Device");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.device");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for browser", () => {
|
|
||||||
const result = getMetadataFieldLabel("browser", mockT);
|
|
||||||
expect(result).toBe("Browser");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.browser");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for url", () => {
|
|
||||||
const result = getMetadataFieldLabel("url", mockT);
|
|
||||||
expect(result).toBe("URL");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("common.url");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct label for source", () => {
|
|
||||||
const result = getMetadataFieldLabel("source", mockT);
|
|
||||||
expect(result).toBe("Source");
|
|
||||||
expect(mockT).toHaveBeenCalledWith("environments.surveys.responses.source");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns capitalized label for unknown field", () => {
|
|
||||||
const result = getMetadataFieldLabel("customField", mockT);
|
|
||||||
expect(result).toBe("Customfield");
|
|
||||||
expect(mockT).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns capitalized label for field with underscores", () => {
|
|
||||||
const result = getMetadataFieldLabel("custom_field", mockT);
|
|
||||||
expect(result).toBe("Custom_field");
|
|
||||||
expect(mockT).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("COLUMNS_ICON_MAP", () => {
|
|
||||||
test("contains correct icon mappings", () => {
|
|
||||||
expect(COLUMNS_ICON_MAP.action).toBe(MousePointerClickIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.country).toBe(FlagIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.browser).toBe(GlobeIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.os).toBe(AirplayIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.device).toBe(SmartphoneIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.source).toBe(ArrowUpFromDotIcon);
|
|
||||||
expect(COLUMNS_ICON_MAP.url).toBe(GlobeIcon);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getMetadataValue", () => {
|
|
||||||
test("returns correct value for action", () => {
|
|
||||||
const result = getMetadataValue({ action: "action_column" }, "action");
|
|
||||||
expect(result).toBe("action_column");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns correct value for userAgent", () => {
|
|
||||||
const result = getMetadataValue({ userAgent: { browser: "browser_column" } }, "browser");
|
|
||||||
expect(result).toBe("browser_column");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
-88
@@ -1,88 +0,0 @@
|
|||||||
import { TFnType } from "@tolgee/react";
|
|
||||||
import { capitalize } from "lodash";
|
|
||||||
import {
|
|
||||||
AirplayIcon,
|
|
||||||
ArrowUpFromDotIcon,
|
|
||||||
FlagIcon,
|
|
||||||
GlobeIcon,
|
|
||||||
MousePointerClickIcon,
|
|
||||||
SmartphoneIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { TResponseMeta } from "@formbricks/types/responses";
|
|
||||||
|
|
||||||
export const getAddressFieldLabel = (field: string, t: TFnType) => {
|
|
||||||
switch (field) {
|
|
||||||
case "addressLine1":
|
|
||||||
return t("environments.surveys.responses.address_line_1");
|
|
||||||
case "addressLine2":
|
|
||||||
return t("environments.surveys.responses.address_line_2");
|
|
||||||
case "city":
|
|
||||||
return t("environments.surveys.responses.city");
|
|
||||||
case "state":
|
|
||||||
return t("environments.surveys.responses.state_region");
|
|
||||||
case "zip":
|
|
||||||
return t("environments.surveys.responses.zip_post_code");
|
|
||||||
case "country":
|
|
||||||
return t("environments.surveys.responses.country");
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getContactInfoFieldLabel = (field: string, t: TFnType) => {
|
|
||||||
switch (field) {
|
|
||||||
case "firstName":
|
|
||||||
return t("environments.surveys.responses.first_name");
|
|
||||||
case "lastName":
|
|
||||||
return t("environments.surveys.responses.last_name");
|
|
||||||
case "email":
|
|
||||||
return t("environments.surveys.responses.email");
|
|
||||||
case "phone":
|
|
||||||
return t("environments.surveys.responses.phone");
|
|
||||||
case "company":
|
|
||||||
return t("environments.surveys.responses.company");
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMetadataFieldLabel = (label: string, t: TFnType) => {
|
|
||||||
switch (label) {
|
|
||||||
case "action":
|
|
||||||
return t("common.action");
|
|
||||||
case "country":
|
|
||||||
return t("environments.surveys.responses.country");
|
|
||||||
case "os":
|
|
||||||
return t("environments.surveys.responses.os");
|
|
||||||
case "device":
|
|
||||||
return t("environments.surveys.responses.device");
|
|
||||||
case "browser":
|
|
||||||
return t("environments.surveys.responses.browser");
|
|
||||||
case "url":
|
|
||||||
return t("common.url");
|
|
||||||
case "source":
|
|
||||||
return t("environments.surveys.responses.source");
|
|
||||||
default:
|
|
||||||
return capitalize(label);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const COLUMNS_ICON_MAP = {
|
|
||||||
action: MousePointerClickIcon,
|
|
||||||
country: FlagIcon,
|
|
||||||
browser: GlobeIcon,
|
|
||||||
os: AirplayIcon,
|
|
||||||
device: SmartphoneIcon,
|
|
||||||
source: ArrowUpFromDotIcon,
|
|
||||||
url: GlobeIcon,
|
|
||||||
};
|
|
||||||
|
|
||||||
const userAgentFields = ["browser", "os", "device"];
|
|
||||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
|
||||||
|
|
||||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
|
||||||
if (userAgentFields.includes(label)) {
|
|
||||||
return meta.userAgent?.[label];
|
|
||||||
}
|
|
||||||
return meta[label];
|
|
||||||
};
|
|
||||||
-19
@@ -10,11 +10,8 @@ import { getSurvey } from "@/lib/survey/service";
|
|||||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
import { TEnvironmentAuth } from "@/modules/environments/types/environment-auth";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
|
||||||
import { cleanup, render, screen } from "@testing-library/react";
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
@@ -105,19 +102,6 @@ vi.mock("@/modules/ui/components/page-content-wrapper", () => ({
|
|||||||
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
|
PageContentWrapper: vi.fn(({ children }) => <div data-testid="page-content-wrapper">{children}</div>),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/modules/survey/lib/organization", () => ({
|
|
||||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
|
||||||
getOrganizationBilling: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
|
||||||
getIsQuotasEnabled: vi.fn(),
|
|
||||||
getIsContactsEnabled: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/modules/ui/components/page-header", () => ({
|
vi.mock("@/modules/ui/components/page-header", () => ({
|
||||||
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
|
PageHeader: vi.fn(({ pageTitle, children, cta }) => (
|
||||||
<div data-testid="page-header">
|
<div data-testid="page-header">
|
||||||
@@ -210,9 +194,6 @@ describe("ResponsesPage", () => {
|
|||||||
|
|
||||||
test("renders correctly with all data", async () => {
|
test("renders correctly with all data", async () => {
|
||||||
const props = { params: mockParams };
|
const props = { params: mockParams };
|
||||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue("mock-organization-id");
|
|
||||||
vi.mocked(getOrganizationBilling).mockResolvedValue({ plan: "scale" });
|
|
||||||
vi.mocked(getIsQuotasEnabled).mockResolvedValue(false);
|
|
||||||
const jsx = await Page(props);
|
const jsx = await Page(props);
|
||||||
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
render(<ResponseFilterProvider>{jsx}</ResponseFilterProvider>);
|
||||||
|
|
||||||
|
|||||||
+1
-19
@@ -10,11 +10,8 @@ import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
|||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
import { getSegments } from "@/modules/ee/contacts/segments/lib/segments";
|
||||||
import { getIsContactsEnabled, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getQuotas } from "@/modules/ee/quotas/lib/quotas";
|
|
||||||
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
|
||||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
@@ -49,19 +46,6 @@ const Page = async (props) => {
|
|||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
|
||||||
if (!organizationId) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
|
||||||
if (!organizationBilling) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
|
||||||
|
|
||||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -91,8 +75,6 @@ const Page = async (props) => {
|
|||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
|
||||||
quotas={quotas}
|
|
||||||
/>
|
/>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-132
@@ -7,13 +7,6 @@ vi.mock("@/modules/ui/components/avatars", () => ({
|
|||||||
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
|
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
|
||||||
}));
|
}));
|
||||||
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
|
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
|
||||||
vi.mock("@/modules/ui/components/id-badge", () => ({
|
|
||||||
IdBadge: ({ id }: { id: string }) => (
|
|
||||||
<div data-testid="id-badge" data-id={id}>
|
|
||||||
ID: {id}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("MultipleChoiceSummary", () => {
|
describe("MultipleChoiceSummary", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -167,8 +160,8 @@ describe("MultipleChoiceSummary", () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const btns = screen.getAllByRole("button");
|
const btns = screen.getAllByRole("button");
|
||||||
expect(btns[0]).toHaveTextContent("2 - YID: other2 common.selections50%");
|
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
|
||||||
expect(btns[1]).toHaveTextContent("1 - XID: other1 common.selection50%");
|
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("places choice with others after one without when reversed inputs", () => {
|
test("places choice with others after one without when reversed inputs", () => {
|
||||||
@@ -279,127 +272,4 @@ describe("MultipleChoiceSummary", () => {
|
|||||||
["O5"]
|
["O5"]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// New tests for IdBadge functionality
|
|
||||||
test("renders IdBadge when choice ID is found", () => {
|
|
||||||
const setFilter = vi.fn();
|
|
||||||
const q = {
|
|
||||||
question: {
|
|
||||||
id: "q6",
|
|
||||||
headline: "H6",
|
|
||||||
type: "multipleChoiceSingle",
|
|
||||||
choices: [
|
|
||||||
{ id: "choice1", label: { default: "Option A" } },
|
|
||||||
{ id: "choice2", label: { default: "Option B" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
choices: {
|
|
||||||
"Option A": { value: "Option A", count: 5, percentage: 50, others: [] },
|
|
||||||
"Option B": { value: "Option B", count: 5, percentage: 50, others: [] },
|
|
||||||
},
|
|
||||||
type: "multipleChoiceSingle",
|
|
||||||
selectionCount: 0,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MultipleChoiceSummary
|
|
||||||
questionSummary={q}
|
|
||||||
environmentId="env"
|
|
||||||
surveyType="link"
|
|
||||||
survey={baseSurvey}
|
|
||||||
setFilter={setFilter}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const idBadges = screen.getAllByTestId("id-badge");
|
|
||||||
expect(idBadges).toHaveLength(2);
|
|
||||||
expect(idBadges[0]).toHaveAttribute("data-id", "choice1");
|
|
||||||
expect(idBadges[1]).toHaveAttribute("data-id", "choice2");
|
|
||||||
expect(idBadges[0]).toHaveTextContent("ID: choice1");
|
|
||||||
expect(idBadges[1]).toHaveTextContent("ID: choice2");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("getChoiceIdByValue function correctly maps values to IDs", () => {
|
|
||||||
const setFilter = vi.fn();
|
|
||||||
const q = {
|
|
||||||
question: {
|
|
||||||
id: "q8",
|
|
||||||
headline: "H8",
|
|
||||||
type: "multipleChoiceMulti",
|
|
||||||
choices: [
|
|
||||||
{ id: "id-apple", label: { default: "Apple" } },
|
|
||||||
{ id: "id-banana", label: { default: "Banana" } },
|
|
||||||
{ id: "id-cherry", label: { default: "Cherry" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
choices: {
|
|
||||||
Apple: { value: "Apple", count: 3, percentage: 30, others: [] },
|
|
||||||
Banana: { value: "Banana", count: 4, percentage: 40, others: [] },
|
|
||||||
Cherry: { value: "Cherry", count: 3, percentage: 30, others: [] },
|
|
||||||
},
|
|
||||||
type: "multipleChoiceMulti",
|
|
||||||
selectionCount: 0,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MultipleChoiceSummary
|
|
||||||
questionSummary={q}
|
|
||||||
environmentId="env"
|
|
||||||
surveyType="link"
|
|
||||||
survey={baseSurvey}
|
|
||||||
setFilter={setFilter}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const idBadges = screen.getAllByTestId("id-badge");
|
|
||||||
expect(idBadges).toHaveLength(3);
|
|
||||||
|
|
||||||
// Check that each badge has the correct ID
|
|
||||||
const expectedMappings = [
|
|
||||||
{ text: "Banana", id: "id-banana" }, // Highest count appears first
|
|
||||||
{ text: "Apple", id: "id-apple" },
|
|
||||||
{ text: "Cherry", id: "id-cherry" },
|
|
||||||
];
|
|
||||||
|
|
||||||
expectedMappings.forEach(({ text, id }, index) => {
|
|
||||||
expect(screen.getByText(`${3 - index} - ${text}`)).toBeInTheDocument();
|
|
||||||
expect(idBadges[index]).toHaveAttribute("data-id", id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles choices with special characters in labels", () => {
|
|
||||||
const setFilter = vi.fn();
|
|
||||||
const q = {
|
|
||||||
question: {
|
|
||||||
id: "q9",
|
|
||||||
headline: "H9",
|
|
||||||
type: "multipleChoiceSingle",
|
|
||||||
choices: [
|
|
||||||
{ id: "special-1", label: { default: "Option & Choice" } },
|
|
||||||
{ id: "special-2", label: { default: "Choice with 'quotes'" } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
choices: {
|
|
||||||
"Option & Choice": { value: "Option & Choice", count: 2, percentage: 50, others: [] },
|
|
||||||
"Choice with 'quotes'": { value: "Choice with 'quotes'", count: 2, percentage: 50, others: [] },
|
|
||||||
},
|
|
||||||
type: "multipleChoiceSingle",
|
|
||||||
selectionCount: 0,
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
render(
|
|
||||||
<MultipleChoiceSummary
|
|
||||||
questionSummary={q}
|
|
||||||
environmentId="env"
|
|
||||||
surveyType="link"
|
|
||||||
survey={baseSurvey}
|
|
||||||
setFilter={setFilter}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const idBadges = screen.getAllByTestId("id-badge");
|
|
||||||
expect(idBadges).toHaveLength(2);
|
|
||||||
expect(idBadges[0]).toHaveAttribute("data-id", "special-1");
|
|
||||||
expect(idBadges[1]).toHaveAttribute("data-id", "special-2");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user