mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-20 02:30:18 -05:00
Compare commits
147 Commits
fix-delete
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f5b2bf69d | ||
|
|
60e7c7e8ee | ||
|
|
7988d7775c | ||
|
|
b7ede6c578 | ||
|
|
8204a5c652 | ||
|
|
e823e10f9a | ||
|
|
f5c3212b2c | ||
|
|
2d66fc6987 | ||
|
|
652970003d | ||
|
|
a8b5e286b6 | ||
|
|
322f0be197 | ||
|
|
1a02f91afd | ||
|
|
cc22ccb22d | ||
|
|
12763f0ef6 | ||
|
|
d39e3ee638 | ||
|
|
d85242a86b | ||
|
|
ef53065abc | ||
|
|
805c1c6874 | ||
|
|
01687e8907 | ||
|
|
31d455002d | ||
|
|
d96304d86d | ||
|
|
1064f68435 | ||
|
|
3d16e859c6 | ||
|
|
af198c5632 | ||
|
|
a43ed2b25c | ||
|
|
87bcad2b20 | ||
|
|
b5eaa4c7fd | ||
|
|
995c03bc01 | ||
|
|
b4395a48c5 | ||
|
|
461e3893fe | ||
|
|
735a9f84ec | ||
|
|
8cb8d734cf | ||
|
|
44d5530b48 | ||
|
|
a314eb391e | ||
|
|
6c34c316d0 | ||
|
|
4f26278f16 | ||
|
|
b975e7fa2e | ||
|
|
6c3052f9e4 | ||
|
|
5bb8119ebf | ||
|
|
02411277d4 | ||
|
|
4cfb8c6d7b | ||
|
|
e74a51a5ff | ||
|
|
29cc6a10fe | ||
|
|
01f765e969 | ||
|
|
9366960f18 | ||
|
|
697dc9cc99 | ||
|
|
83bc272ed2 | ||
|
|
59cc9c564e | ||
|
|
20dc147682 | ||
|
|
2bb7a6f277 | ||
|
|
deb062dd03 | ||
|
|
474be86d33 | ||
|
|
e7ca66ed77 | ||
|
|
2b49dbecd3 | ||
|
|
6da4c6f352 | ||
|
|
659b240fca | ||
|
|
19c0b1d14d | ||
|
|
b4472f48e9 | ||
|
|
d197271771 | ||
|
|
37f652c70e | ||
|
|
645f0ab0d1 | ||
|
|
389a7d9e7b | ||
|
|
c4cf468c7e | ||
|
|
cbc3e923e4 | ||
|
|
a96ba8b1e7 | ||
|
|
e830871361 | ||
|
|
998e5c0819 | ||
|
|
13a56b0237 | ||
|
|
0b5418a03a | ||
|
|
0d8a338965 | ||
|
|
d3250736a9 | ||
|
|
e6ee6a6b0d | ||
|
|
c0b097f929 | ||
|
|
78d336f8c7 | ||
|
|
95a7a265b9 | ||
|
|
136e59da68 | ||
|
|
eb0a87cf80 | ||
|
|
0dcb98ac29 | ||
|
|
540f7aaae7 | ||
|
|
2d4614a0bd | ||
|
|
633bf18204 | ||
|
|
9a6cbd05b6 | ||
|
|
94b0248075 | ||
|
|
082de1042d | ||
|
|
8c19587baa | ||
|
|
433750d3fe | ||
|
|
61befd5ffd | ||
|
|
1e7817fb69 | ||
|
|
f250bc7e88 | ||
|
|
c7faa29437 | ||
|
|
a51a006c26 | ||
|
|
ce96cb0b89 | ||
|
|
fb265d9dba | ||
|
|
e4c155b501 | ||
|
|
2dc5c50f4d | ||
|
|
bddcec0466 | ||
|
|
92677e1ec0 | ||
|
|
b12228e305 | ||
|
|
91be2af30b | ||
|
|
84c668be86 | ||
|
|
4015c76f2b | ||
|
|
a7b2ade4a9 | ||
|
|
75f44952c7 | ||
|
|
0df5e26381 | ||
|
|
89bb3bcd84 | ||
|
|
30fdb72c09 | ||
|
|
cb58cf5825 | ||
|
|
99bd2ba256 | ||
|
|
9df423073f | ||
|
|
3e3c696972 | ||
|
|
cb41e2d344 | ||
|
|
1e19cca7d9 | ||
|
|
fa882dd4cc | ||
|
|
0b82c6de77 | ||
|
|
a944d7626e | ||
|
|
d1b12dc228 | ||
|
|
9f7d6038b1 | ||
|
|
1da92addd2 | ||
|
|
1e4aa5f54b | ||
|
|
96f173c3b1 | ||
|
|
9c9e55fba6 | ||
|
|
42541f86fd | ||
|
|
0ba469a73d | ||
|
|
afa192e5b9 | ||
|
|
4860a9a5cf | ||
|
|
af02ce9ea6 | ||
|
|
fc1c91896a | ||
|
|
f5c7dbdc71 | ||
|
|
b88ea5cc66 | ||
|
|
f31085a9e7 | ||
|
|
2ab0441404 | ||
|
|
299ae81b21 | ||
|
|
f73f13f16c | ||
|
|
e9bcbf6e4c | ||
|
|
32eda35a71 | ||
|
|
84999cddfd | ||
|
|
f0a0cf531a | ||
|
|
f3e02fa466 | ||
|
|
f0a93ae092 | ||
|
|
1c922dfe2c | ||
|
|
33010fb6f5 | ||
|
|
d5fdacadd7 | ||
|
|
d939263472 | ||
|
|
e4aa66b067 | ||
|
|
ffcc101ed9 | ||
|
|
2740cd16b9 | ||
|
|
7eb94f0bd5 |
9
.codex/environments/environment.toml
Normal file
9
.codex/environments/environment.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||||
|
version = 1
|
||||||
|
name = "formbricks"
|
||||||
|
|
||||||
|
[setup]
|
||||||
|
script = '''
|
||||||
|
pnpm install
|
||||||
|
pnpm dev:setup
|
||||||
|
'''
|
||||||
42
.env.example
42
.env.example
@@ -94,6 +94,12 @@ EMAIL_VERIFICATION_DISABLED=1
|
|||||||
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
# Password Reset. If you enable Password Reset functionality you have to setup SMTP-Settings, too.
|
||||||
PASSWORD_RESET_DISABLED=1
|
PASSWORD_RESET_DISABLED=1
|
||||||
|
|
||||||
|
# Password reset token lifetime in minutes. Must be between 5 and 120 if set.
|
||||||
|
# PASSWORD_RESET_TOKEN_LIFETIME_MINUTES=30
|
||||||
|
|
||||||
|
# Development-only helper: log the password reset link to the server console instead of sending reset emails.
|
||||||
|
# DEBUG_SHOW_RESET_LINK=1
|
||||||
|
|
||||||
# Email login. Disable the ability for users to login with email.
|
# Email login. Disable the ability for users to login with email.
|
||||||
# EMAIL_AUTH_DISABLED=1
|
# EMAIL_AUTH_DISABLED=1
|
||||||
|
|
||||||
@@ -132,6 +138,31 @@ AZUREAD_CLIENT_ID=
|
|||||||
AZUREAD_CLIENT_SECRET=
|
AZUREAD_CLIENT_SECRET=
|
||||||
AZUREAD_TENANT_ID=
|
AZUREAD_TENANT_ID=
|
||||||
|
|
||||||
|
# Configure Formbricks AI at the instance level
|
||||||
|
# Set the provider used for AI features on this instance.
|
||||||
|
# Accepted values for AI_PROVIDER: aws, gcp, azure
|
||||||
|
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
|
||||||
|
# AI_PROVIDER=gcp
|
||||||
|
# AI_MODEL=gemini-2.5-flash
|
||||||
|
|
||||||
|
# Google Vertex AI credentials
|
||||||
|
# AI_GCP_PROJECT=
|
||||||
|
# AI_GCP_LOCATION=
|
||||||
|
# AI_GCP_CREDENTIALS_JSON=
|
||||||
|
# AI_GCP_APPLICATION_CREDENTIALS=
|
||||||
|
|
||||||
|
# Amazon Bedrock credentials
|
||||||
|
# AI_AWS_REGION=
|
||||||
|
# AI_AWS_ACCESS_KEY_ID=
|
||||||
|
# AI_AWS_SECRET_ACCESS_KEY=
|
||||||
|
# AI_AWS_SESSION_TOKEN=
|
||||||
|
|
||||||
|
# Azure AI / Microsoft Foundry credentials
|
||||||
|
# AI_AZURE_BASE_URL=
|
||||||
|
# AI_AZURE_RESOURCE_NAME=
|
||||||
|
# AI_AZURE_API_KEY=
|
||||||
|
# AI_AZURE_API_VERSION=v1
|
||||||
|
|
||||||
# OpenID Connect (OIDC) configuration
|
# OpenID Connect (OIDC) configuration
|
||||||
# OIDC_CLIENT_ID=
|
# OIDC_CLIENT_ID=
|
||||||
# OIDC_CLIENT_SECRET=
|
# OIDC_CLIENT_SECRET=
|
||||||
@@ -150,6 +181,7 @@ NOTION_OAUTH_CLIENT_ID=
|
|||||||
NOTION_OAUTH_CLIENT_SECRET=
|
NOTION_OAUTH_CLIENT_SECRET=
|
||||||
|
|
||||||
# Stripe Billing Variables
|
# Stripe Billing Variables
|
||||||
|
STRIPE_PUBLISHABLE_KEY=
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
@@ -184,6 +216,14 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
# Ignore Rate Limiting across the Formbricks app
|
# Ignore Rate Limiting across the Formbricks app
|
||||||
# RATE_LIMITING_DISABLED=1
|
# RATE_LIMITING_DISABLED=1
|
||||||
|
|
||||||
|
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||||
|
# TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||||
|
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||||
|
# that need to send webhooks to internal services.
|
||||||
|
# DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS=1
|
||||||
|
|
||||||
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
# OpenTelemetry OTLP endpoint (base URL, exporters append /v1/traces and /v1/metrics)
|
||||||
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||||
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||||
@@ -230,4 +270,4 @@ REDIS_URL=redis://localhost:6379
|
|||||||
|
|
||||||
|
|
||||||
# Lingo.dev API key for translation generation
|
# Lingo.dev API key for translation generation
|
||||||
LINGODOTDEV_API_KEY=your_api_key_here
|
LINGO_API_KEY=your_api_key_here
|
||||||
|
|||||||
@@ -285,12 +285,14 @@ runs:
|
|||||||
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
encryption_key=${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
redis_url=${{ env.DUMMY_REDIS_URL }}
|
redis_url=${{ env.DUMMY_REDIS_URL }}
|
||||||
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
sentry_auth_token=${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
posthog_key=${{ env.POSTHOG_KEY }}
|
||||||
env:
|
env:
|
||||||
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
DEPOT_PROJECT_TOKEN: ${{ env.DEPOT_PROJECT_TOKEN }}
|
||||||
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
DUMMY_DATABASE_URL: ${{ env.DUMMY_DATABASE_URL }}
|
||||||
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ env.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ env.DUMMY_REDIS_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ env.SENTRY_AUTH_TOKEN }}
|
||||||
|
POSTHOG_KEY: ${{ env.POSTHOG_KEY }}
|
||||||
|
|
||||||
- name: Sign GHCR image (GHCR only)
|
- name: Sign GHCR image (GHCR only)
|
||||||
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
if: ${{ inputs.registry_type == 'ghcr' && (github.event_name == 'workflow_call' || github.event_name == 'release' || github.event_name == 'workflow_dispatch') }}
|
||||||
|
|||||||
1
.github/workflows/build-and-push-ecr.yml
vendored
1
.github/workflows/build-and-push-ecr.yml
vendored
@@ -92,3 +92,4 @@ jobs:
|
|||||||
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
DUMMY_ENCRYPTION_KEY: ${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
DUMMY_REDIS_URL: ${{ secrets.DUMMY_REDIS_URL }}
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||||
|
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -45,7 +45,7 @@ yarn-error.log*
|
|||||||
.direnv
|
.direnv
|
||||||
|
|
||||||
# Playwright
|
# Playwright
|
||||||
/test-results/
|
**/test-results/
|
||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
|
||||||
prettier --write ./branch.json
|
|
||||||
@@ -1 +1,13 @@
|
|||||||
pnpm lint-staged
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
if command -v pnpm >/dev/null 2>&1; then
|
||||||
|
pnpm lint-staged
|
||||||
|
elif command -v npm >/dev/null 2>&1; then
|
||||||
|
npm exec --yes pnpm@10.32.1 lint-staged
|
||||||
|
elif command -v corepack >/dev/null 2>&1; then
|
||||||
|
corepack pnpm lint-staged
|
||||||
|
else
|
||||||
|
echo "Error: pnpm, npm, and corepack are unavailable in this Git hook PATH."
|
||||||
|
echo "Install Node.js tooling or update your PATH, then retry the commit."
|
||||||
|
exit 127
|
||||||
|
fi
|
||||||
@@ -52,6 +52,14 @@ We are using SonarQube to identify code smells and security hotspots.
|
|||||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||||
|
|
||||||
|
## Date and Time Rendering
|
||||||
|
|
||||||
|
- All user-facing dates and times must use shared formatting helpers instead of ad hoc `date-fns`, `Intl`, or `toLocale*` calls in components.
|
||||||
|
- Locale for display must come from the app language source of truth (`user.locale`, `getLocale()`, or `i18n.resolvedLanguage`), not browser defaults or implicit `undefined` locale behavior.
|
||||||
|
- Locale and time zone are different concerns: locale controls formatting, time zone controls the represented clock/calendar moment.
|
||||||
|
- Never infer a time zone from locale. If a product-level time zone source of truth exists, use it explicitly; otherwise preserve the existing semantic meaning of the stored value and avoid introducing browser-dependent conversions.
|
||||||
|
- Machine-facing values for storage, APIs, exports, integrations, and logs must remain stable and non-localized (`ISO 8601` / UTC where applicable).
|
||||||
|
|
||||||
## Database & Prisma Performance
|
## Database & Prisma Performance
|
||||||
|
|
||||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
|
|||||||
|
|
||||||
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
|
||||||
|
|
||||||
If you opt for self-hosting Formbricks, here are a few options to consider:
|
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
|
|
||||||
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
|
||||||
|
|
||||||
#### Community-managed One Click Hosting
|
|
||||||
|
|
||||||
##### Railway
|
|
||||||
|
|
||||||
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
|
|
||||||
|
|
||||||
[](https://railway.app/new/template/PPDzCd)
|
|
||||||
|
|
||||||
##### RepoCloud
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
|
||||||
|
|
||||||
[](https://repocloud.io/details/?app_id=254)
|
|
||||||
|
|
||||||
##### Zeabur
|
|
||||||
|
|
||||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
|
||||||
|
|
||||||
[](https://zeabur.com/templates/G4TUJL)
|
|
||||||
|
|
||||||
<a id="development"></a>
|
|
||||||
|
|
||||||
## 👨💻 Development
|
## 👨💻 Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@@ -247,4 +223,4 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
|
|||||||
|
|
||||||
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
|
||||||
|
|
||||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
<a id="readme-de"></a>
|
||||||
|
|||||||
@@ -10,25 +10,20 @@
|
|||||||
"build-storybook": "storybook build",
|
"build-storybook": "storybook build",
|
||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
|
||||||
"@formbricks/survey-ui": "workspace:*"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^5.0.0",
|
"@chromatic-com/storybook": "^5.0.1",
|
||||||
"@storybook/addon-a11y": "10.1.11",
|
"@storybook/addon-a11y": "10.2.17",
|
||||||
"@storybook/addon-links": "10.1.11",
|
"@storybook/addon-links": "10.2.17",
|
||||||
"@storybook/addon-onboarding": "10.1.11",
|
"@storybook/addon-onboarding": "10.2.17",
|
||||||
"@storybook/react-vite": "10.1.11",
|
"@storybook/react-vite": "10.2.17",
|
||||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
"@typescript-eslint/eslint-plugin": "8.57.0",
|
||||||
"@tailwindcss/vite": "4.1.18",
|
"@tailwindcss/vite": "4.2.1",
|
||||||
"@typescript-eslint/parser": "8.53.0",
|
"@typescript-eslint/parser": "8.57.0",
|
||||||
"@vitejs/plugin-react": "5.1.2",
|
"@vitejs/plugin-react": "5.1.4",
|
||||||
"esbuild": "0.25.12",
|
|
||||||
"eslint-plugin-react-refresh": "0.4.26",
|
"eslint-plugin-react-refresh": "0.4.26",
|
||||||
"eslint-plugin-storybook": "10.1.11",
|
"eslint-plugin-storybook": "10.2.17",
|
||||||
"prop-types": "15.8.1",
|
"storybook": "10.2.17",
|
||||||
"storybook": "10.1.11",
|
|
||||||
"vite": "7.3.1",
|
"vite": "7.3.1",
|
||||||
"@storybook/addon-docs": "10.1.11"
|
"@storybook/addon-docs": "10.2.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
apps/web/.prettierrc.js
Normal file
6
apps/web/.prettierrc.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
const baseConfig = require("../../.prettierrc.js");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...baseConfig,
|
||||||
|
tailwindConfig: "./tailwind.config.js",
|
||||||
|
};
|
||||||
@@ -18,7 +18,7 @@ FROM node:24-alpine3.23 AS base
|
|||||||
FROM base AS installer
|
FROM base AS installer
|
||||||
|
|
||||||
# Enable corepack and prepare pnpm
|
# Enable corepack and prepare pnpm
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest
|
||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
RUN corepack prepare pnpm@10.28.2 --activate
|
RUN corepack prepare pnpm@10.28.2 --activate
|
||||||
|
|
||||||
@@ -67,6 +67,7 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
--mount=type=secret,id=encryption_key \
|
--mount=type=secret,id=encryption_key \
|
||||||
--mount=type=secret,id=redis_url \
|
--mount=type=secret,id=redis_url \
|
||||||
--mount=type=secret,id=sentry_auth_token \
|
--mount=type=secret,id=sentry_auth_token \
|
||||||
|
--mount=type=secret,id=posthog_key \
|
||||||
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
#
|
#
|
||||||
@@ -74,9 +75,10 @@ RUN --mount=type=secret,id=database_url \
|
|||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
# Update npm to latest, then create user
|
# Upgrade Alpine system packages to pick up security patches, update npm to latest, then create user
|
||||||
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
# Note: npm's bundled tar has a known vulnerability but npm is only used during build, not at runtime
|
||||||
RUN npm install --ignore-scripts -g npm@latest \
|
RUN apk update && apk upgrade --no-cache \
|
||||||
|
&& npm install --ignore-scripts -g npm@latest \
|
||||||
&& addgroup -S nextjs \
|
&& addgroup -S nextjs \
|
||||||
&& adduser -S -u 1001 -G nextjs nextjs
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
@@ -120,8 +122,11 @@ RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules
|
|||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
|
# Runtime migrations import uuid v7 from the database package, so copy the
|
||||||
RUN chmod -R 755 ./node_modules/uuid
|
# database package's resolved install instead of the repo-root hoisted version.
|
||||||
|
COPY --from=installer /app/packages/database/node_modules/uuid ./node_modules/uuid
|
||||||
|
RUN chmod -R 755 ./node_modules/uuid \
|
||||||
|
&& node --input-type=module -e "import('uuid').then((module) => { if (typeof module.v7 !== 'function') throw new Error('uuid v7 missing in runtime image'); })"
|
||||||
|
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
RUN chmod -R 755 ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
@@ -164,4 +169,4 @@ RUN mkdir -p /home/nextjs/apps/web/uploads/ && \
|
|||||||
VOLUME /home/nextjs/apps/web/uploads/
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||||
<span className="relative flex h-10 w-10">
|
<span className="relative flex h-10 w-10">
|
||||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
@@ -20,12 +21,12 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
@@ -46,7 +47,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { AuthorizationError } from "@formbricks/types/errors";
|
|||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
import { cleanup } from "@testing-library/react";
|
import { cleanup } from "@testing-library/react";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
import { replacePresetPlaceholders } from "./utils";
|
import { replacePresetPlaceholders } from "./utils";
|
||||||
|
|
||||||
@@ -39,13 +40,13 @@ const mockTemplate: TXMTemplate = {
|
|||||||
elements: [
|
elements: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
type: "openText" as const,
|
type: "openText" as TSurveyElementTypeEnum.OpenText,
|
||||||
inputType: "text" as const,
|
inputType: "text" as const,
|
||||||
headline: { default: "$[projectName] Question" },
|
headline: { default: "$[projectName] Question" },
|
||||||
subheader: { default: "" },
|
subheader: { default: "" },
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: "" },
|
placeholder: { default: "" },
|
||||||
charLimit: 1000,
|
charLimit: { enabled: true, max: 1000 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMSurveyDefault returns default survey template", () => {
|
test("getXMSurveyDefault returns default survey template", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFunction;
|
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||||
const result = getXMSurveyDefault(tMock);
|
const result = getXMSurveyDefault(tMock);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
@@ -29,7 +29,7 @@ describe("xm-templates", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("getXMTemplates returns all templates", () => {
|
test("getXMTemplates returns all templates", () => {
|
||||||
const tMock = vi.fn((key) => key) as TFunction;
|
const tMock = vi.fn((key: string) => key) as unknown as TFunction;
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
expect(result).toHaveLength(6);
|
expect(result).toHaveLength(6);
|
||||||
@@ -44,7 +44,7 @@ describe("xm-templates", () => {
|
|||||||
test("getXMTemplates handles errors gracefully", async () => {
|
test("getXMTemplates handles errors gracefully", async () => {
|
||||||
const tMock = vi.fn(() => {
|
const tMock = vi.fn(() => {
|
||||||
throw new Error("Test error");
|
throw new Error("Test error");
|
||||||
}) as TFunction;
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
const result = getXMTemplates(tMock);
|
const result = getXMTemplates(tMock);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
|
||||||
import { getEnvironment } from "@/lib/environment/service";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
@@ -23,22 +24,22 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
@@ -49,7 +50,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{projects.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ describe("getTeamsByOrganizationId", () => {
|
|||||||
|
|
||||||
test("returns mapped teams", async () => {
|
test("returns mapped teams", async () => {
|
||||||
const mockTeams = [
|
const mockTeams = [
|
||||||
{ id: "t1", name: "Team 1" },
|
{ id: "t1", name: "Team 1", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||||
{ id: "t2", name: "Team 2" },
|
{ id: "t2", name: "Team 2", createdAt: new Date(), updatedAt: new Date(), organizationId: "org1" },
|
||||||
];
|
];
|
||||||
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
vi.mocked(prisma.team.findMany).mockResolvedValueOnce(mockTeams);
|
||||||
const result = await getTeamsByOrganizationId("org1");
|
const result = await getTeamsByOrganizationId("org1");
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export const getTeamsByOrganizationId = reactCache(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectTeams = teams.map((team) => ({
|
return teams.map((team: TOrganizationTeam) => ({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return projectTeams;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-sidebar-collapsed 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 w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||||
)}>
|
)}>
|
||||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
|||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const LandingLayout = async (props) => {
|
const LandingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
|||||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
@@ -26,7 +26,8 @@ const Page = async (props) => {
|
|||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
|
|
||||||
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
const membership = await getMembershipByUserIdOrganizationId(session.user.id, organization.id);
|
||||||
const { isMember } = getAccessFlags(membership?.role);
|
const { isMember, isBilling } = getAccessFlags(membership?.role);
|
||||||
|
const isMembershipPending = membership?.role === undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-row">
|
<div className="flex min-h-full min-w-full flex-row">
|
||||||
@@ -45,6 +46,8 @@ const Page = async (props) => {
|
|||||||
isOwnerOrManager={false}
|
isOwnerOrManager={false}
|
||||||
isAccessControlAllowed={false}
|
isAccessControlAllowed={false}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
|
isBilling={isBilling}
|
||||||
|
isMembershipPending={isMembershipPending}
|
||||||
environments={[]}
|
environments={[]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
@@ -8,7 +8,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||||
|
|
||||||
const ProjectOnboardingLayout = async (props) => {
|
const ProjectOnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -22,7 +25,7 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
@@ -33,7 +36,7 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
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 { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
@@ -8,7 +9,10 @@ import { getTranslate } from "@/lingodotdev/server";
|
|||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props: {
|
||||||
|
params: Promise<{ organizationId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -25,11 +29,13 @@ const OnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
|
||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
getOrganizationProjectsLimit(organization.id),
|
||||||
|
getOrganizationProjectsCount(organization.id),
|
||||||
|
]);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
return redirect(`/`);
|
return redirect(`/`);
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
|||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||||
|
import { Header } from "@/modules/ui/components/header";
|
||||||
|
|
||||||
|
interface SelectPlanOnboardingProps {
|
||||||
|
organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||||
|
const t = await getTranslate();
|
||||||
|
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||||
|
<Header
|
||||||
|
title={t("environments.settings.billing.select_plan_header_title")}
|
||||||
|
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
|
||||||
|
/>
|
||||||
|
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TCloudBillingPlan } from "@formbricks/types/organizations";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getOrganizationBillingWithReadThroughSync } from "@/modules/ee/billing/lib/organization-billing";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
|
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||||
|
|
||||||
|
const PAID_PLANS = new Set<TCloudBillingPlan>(["pro", "scale", "custom"]);
|
||||||
|
|
||||||
|
interface PlanPageProps {
|
||||||
|
params: Promise<{
|
||||||
|
organizationId: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Page = async (props: PlanPageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
if (!IS_FORMBRICKS_CLOUD) {
|
||||||
|
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
|
return redirect(`/auth/login`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users with an existing paid/trial subscription should not be shown the trial page.
|
||||||
|
// Redirect them directly to the next onboarding step.
|
||||||
|
const billing = await getOrganizationBillingWithReadThroughSync(params.organizationId);
|
||||||
|
const currentPlan = billing?.stripe?.plan;
|
||||||
|
const hasExistingSubscription = currentPlan !== undefined && PAID_PLANS.has(currentPlan);
|
||||||
|
|
||||||
|
if (hasExistingSubscription) {
|
||||||
|
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||||
{logoUrl && (
|
{logoUrl && (
|
||||||
<Image
|
<Image
|
||||||
src={logoUrl}
|
src={logoUrl}
|
||||||
@@ -239,18 +239,16 @@ export const ProjectSettings = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<SurveyInline
|
||||||
<SurveyInline
|
appUrl={publicDomain}
|
||||||
appUrl={publicDomain}
|
isPreviewMode={true}
|
||||||
isPreviewMode={true}
|
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
styling={previewStyling}
|
||||||
styling={previewStyling}
|
isBrandingEnabled={false}
|
||||||
isBrandingEnabled={false}
|
languageCode="default"
|
||||||
languageCode="default"
|
onFileUpload={async (file) => file.name}
|
||||||
onFileUpload={async (file) => file.name}
|
autoFocus={false}
|
||||||
autoFocus={false}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CreateTeamModal
|
<CreateTeamModal
|
||||||
open={createTeamModalOpen}
|
open={createTeamModalOpen}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
@@ -42,10 +43,10 @@ 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 isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new ResourceNotFoundError(t("common.team"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicDomain = getPublicDomain();
|
const publicDomain = getPublicDomain();
|
||||||
@@ -69,7 +70,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface OnboardingOptionsContainerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
export const OnboardingOptionsContainer = ({ options }: OnboardingOptionsContainerProps) => {
|
||||||
const getOptionCard = (option) => {
|
const getOptionCard = (option: OnboardingOptionsContainerProps["options"][number]) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<OptionCard
|
<OptionCard
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ZOrganizationTeam = z.object({
|
export const ZOrganizationTeam = z.object({
|
||||||
id: z.string().cuid2(),
|
id: z.cuid2(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
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";
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -14,13 +18,13 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
throw new Error(t("common.environment_not_found"));
|
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,15 +6,26 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Confetti } from "@/modules/ui/components/confetti";
|
import { Confetti } from "@/modules/ui/components/confetti";
|
||||||
|
|
||||||
interface ConfirmationPageProps {
|
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
|
||||||
environmentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
export const ConfirmationPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showConfetti, setShowConfetti] = useState(false);
|
const [showConfetti, setShowConfetti] = useState(false);
|
||||||
|
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setShowConfetti(true);
|
setShowConfetti(true);
|
||||||
|
|
||||||
|
if (globalThis.window === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
|
||||||
|
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
|
||||||
|
);
|
||||||
|
if (storedEnvironmentId) {
|
||||||
|
setResolvedEnvironmentId(storedEnvironmentId);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +41,12 @@ export const ConfirmationPage = ({ environmentId }: ConfirmationPageProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild className="w-full justify-center">
|
<Button asChild className="w-full justify-center">
|
||||||
<Link href={`/environments/${environmentId}/settings/billing`}>
|
<Link
|
||||||
|
href={
|
||||||
|
resolvedEnvironmentId
|
||||||
|
? `/environments/${resolvedEnvironmentId}/settings/billing`
|
||||||
|
: "/environments"
|
||||||
|
}>
|
||||||
{t("billing_confirmation.back_to_billing_overview")}
|
{t("billing_confirmation.back_to_billing_overview")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,13 +3,10 @@ import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async () => {
|
||||||
const searchParams = await props.searchParams;
|
|
||||||
const { environmentId } = searchParams;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<ConfirmationPage environmentId={environmentId?.toString()} />
|
<ConfirmationPage />
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
import {
|
||||||
|
AuthorizationError,
|
||||||
|
OperationNotAllowedError,
|
||||||
|
ResourceNotFoundError,
|
||||||
|
} from "@formbricks/types/errors";
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
import { getOrganization } from "@/lib/organization/service";
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
@@ -10,7 +14,6 @@ import { getOrganizationProjectsCount } from "@/lib/project/service";
|
|||||||
import { updateUser } from "@/lib/user/service";
|
import { updateUser } from "@/lib/user/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import {
|
import {
|
||||||
getAccessControlPermission,
|
getAccessControlPermission,
|
||||||
@@ -25,67 +28,63 @@ const ZCreateProjectAction = z.object({
|
|||||||
data: ZProjectUpdateInput,
|
data: ZProjectUpdateInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
|
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging("created", "project", async ({ ctx, parsedInput }) => {
|
||||||
"created",
|
const { user } = ctx;
|
||||||
"project",
|
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
|
||||||
const { user } = ctx;
|
|
||||||
|
|
||||||
const organizationId = parsedInput.organizationId;
|
const organizationId = parsedInput.organizationId;
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
organizationId: parsedInput.organizationId,
|
organizationId: parsedInput.organizationId,
|
||||||
access: [
|
access: [
|
||||||
{
|
{
|
||||||
data: parsedInput.data,
|
data: parsedInput.data,
|
||||||
schema: ZProjectUpdateInput,
|
schema: ZProjectUpdateInput,
|
||||||
type: "organization",
|
type: "organization",
|
||||||
roles: ["owner", "manager"],
|
roles: ["owner", "manager"],
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const organization = await getOrganization(organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error("Organization not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
|
||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
|
||||||
throw new OperationNotAllowedError("Organization workspace limit reached");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
|
||||||
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
|
|
||||||
|
|
||||||
if (!isAccessControlAllowed) {
|
|
||||||
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
|
||||||
const updatedNotificationSettings = {
|
|
||||||
...user.notificationSettings,
|
|
||||||
alert: {
|
|
||||||
...user.notificationSettings?.alert,
|
|
||||||
},
|
},
|
||||||
};
|
],
|
||||||
|
});
|
||||||
|
|
||||||
await updateUser(user.id, {
|
const organization = await getOrganization(organizationId);
|
||||||
notificationSettings: updatedNotificationSettings,
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = organizationId;
|
if (!organization) {
|
||||||
ctx.auditLoggingCtx.projectId = project.id;
|
throw new ResourceNotFoundError("Organization", organizationId);
|
||||||
ctx.auditLoggingCtx.newObject = project;
|
|
||||||
return project;
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||||
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
|
|
||||||
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
const isAccessControlAllowed = await getAccessControlPermission(organization.id);
|
||||||
|
|
||||||
|
if (!isAccessControlAllowed) {
|
||||||
|
throw new OperationNotAllowedError("You do not have permission to manage roles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await createProject(parsedInput.organizationId, parsedInput.data);
|
||||||
|
const updatedNotificationSettings = {
|
||||||
|
...user.notificationSettings,
|
||||||
|
alert: {
|
||||||
|
...user.notificationSettings?.alert,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateUser(user.id, {
|
||||||
|
notificationSettings: updatedNotificationSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
ctx.auditLoggingCtx.projectId = project.id;
|
||||||
|
ctx.auditLoggingCtx.newObject = project;
|
||||||
|
return project;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZGetOrganizationsForSwitcherAction = z.object({
|
const ZGetOrganizationsForSwitcherAction = z.object({
|
||||||
@@ -97,7 +96,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
|
|||||||
* Called on-demand when user opens the organization switcher.
|
* Called on-demand when user opens the organization switcher.
|
||||||
*/
|
*/
|
||||||
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
export const getOrganizationsForSwitcherAction = authenticatedActionClient
|
||||||
.schema(ZGetOrganizationsForSwitcherAction)
|
.inputSchema(ZGetOrganizationsForSwitcherAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -122,7 +121,7 @@ const ZGetProjectsForSwitcherAction = z.object({
|
|||||||
* Called on-demand when user opens the project switcher.
|
* Called on-demand when user opens the project switcher.
|
||||||
*/
|
*/
|
||||||
export const getProjectsForSwitcherAction = authenticatedActionClient
|
export const getProjectsForSwitcherAction = authenticatedActionClient
|
||||||
.schema(ZGetProjectsForSwitcherAction)
|
.inputSchema(ZGetProjectsForSwitcherAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
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 { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
@@ -29,7 +30,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
projectPermission,
|
projectPermission,
|
||||||
license,
|
license,
|
||||||
peopleCount,
|
|
||||||
responseCount,
|
responseCount,
|
||||||
} = layoutData;
|
} = layoutData;
|
||||||
|
|
||||||
@@ -38,12 +38,12 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
const { features, lastChecked, isPendingDowngrade, active, status } = license;
|
||||||
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
|
||||||
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
|
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.id);
|
||||||
const isOwnerOrManager = isOwner || isManager;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.workspace_permission_not_found"));
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,7 +52,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
<LimitsReachedBanner
|
<LimitsReachedBanner
|
||||||
organization={organization}
|
organization={organization}
|
||||||
environmentId={environment.id}
|
environmentId={environment.id}
|
||||||
peopleCount={peopleCount}
|
|
||||||
responseCount={responseCount}
|
responseCount={responseCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -76,6 +75,10 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
publicDomain={publicDomain}
|
publicDomain={publicDomain}
|
||||||
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
|
isLicenseActive={active}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -2,40 +2,59 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowUpRightIcon,
|
ArrowUpRightIcon,
|
||||||
|
Building2Icon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Cog,
|
Cog,
|
||||||
|
FoldersIcon,
|
||||||
|
Loader2,
|
||||||
LogOutIcon,
|
LogOutIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
|
PlusIcon,
|
||||||
RocketIcon,
|
RocketIcon,
|
||||||
|
SettingsIcon,
|
||||||
UserCircleIcon,
|
UserCircleIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
} 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";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
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 { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import {
|
||||||
|
getOrganizationsForSwitcherAction,
|
||||||
|
getProjectsForSwitcherAction,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/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 { 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 { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
|
import { TrialAlert } from "@/modules/ee/billing/components/trial-alert";
|
||||||
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
|
import { CreateProjectModal } from "@/modules/projects/components/create-project-modal";
|
||||||
|
import { ProjectLimitModal } from "@/modules/projects/components/project-limit-modal";
|
||||||
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
import { getLatestStableFbReleaseAction } from "@/modules/projects/settings/(setup)/app-connection/actions";
|
||||||
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,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import packageJson from "../../../../../package.json";
|
import packageJson from "../../../../../package.json";
|
||||||
|
|
||||||
interface NavigationProps {
|
interface NavigationProps {
|
||||||
@@ -47,8 +66,31 @@ interface NavigationProps {
|
|||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
|
isMultiOrgEnabled: boolean;
|
||||||
|
organizationProjectsLimit: number;
|
||||||
|
isLicenseActive: boolean;
|
||||||
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
if (pathname.includes("/settings/")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
|
const accountSettingsPattern = /\/settings\/(profile|account|notifications|security|appearance)(?:\/|$)/;
|
||||||
|
if (accountSettingsPattern.test(pathname)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = new RegExp(`/settings/${settingId}(?:/|$)`);
|
||||||
|
return pattern.test(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
environment,
|
environment,
|
||||||
organization,
|
organization,
|
||||||
@@ -58,6 +100,10 @@ export const MainNavigation = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
publicDomain,
|
publicDomain,
|
||||||
|
isMultiOrgEnabled,
|
||||||
|
organizationProjectsLimit,
|
||||||
|
isLicenseActive,
|
||||||
|
isAccessControlAllowed,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -67,7 +113,12 @@ export const MainNavigation = ({
|
|||||||
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 { isManager, isOwner, isBilling } = getAccessFlags(membershipRole);
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const { isManager, isOwner, isBilling, isMember } = getAccessFlags(membershipRole);
|
||||||
|
const isMembershipPending = membershipRole === undefined;
|
||||||
|
const disabledNavigationMessage = isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action");
|
||||||
|
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
@@ -104,6 +155,7 @@ export const MainNavigation = ({
|
|||||||
icon: MessageCircle,
|
icon: MessageCircle,
|
||||||
isActive: pathname?.includes("/surveys"),
|
isActive: pathname?.includes("/surveys"),
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
|
disabled: isMembershipPending || isBilling,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: `/environments/${environment.id}/contacts`,
|
href: `/environments/${environment.id}/contacts`,
|
||||||
@@ -113,15 +165,17 @@ export const MainNavigation = ({
|
|||||||
pathname?.includes("/contacts") ||
|
pathname?.includes("/contacts") ||
|
||||||
pathname?.includes("/segments") ||
|
pathname?.includes("/segments") ||
|
||||||
pathname?.includes("/attributes"),
|
pathname?.includes("/attributes"),
|
||||||
|
disabled: isMembershipPending || isBilling,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/workspace/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/workspace"),
|
||||||
|
disabled: isMembershipPending || isBilling,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, environment.id, pathname]
|
[t, environment.id, pathname, isMembershipPending, isBilling]
|
||||||
);
|
);
|
||||||
|
|
||||||
const dropdownNavigation = [
|
const dropdownNavigation = [
|
||||||
@@ -144,6 +198,183 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const [isWorkspaceDropdownOpen, setIsWorkspaceDropdownOpen] = useState(false);
|
||||||
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
|
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [organizations, setOrganizations] = useState<{ id: string; name: string }[]>([]);
|
||||||
|
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
|
||||||
|
const [hasInitializedProjects, setHasInitializedProjects] = useState(false);
|
||||||
|
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
|
||||||
|
const [workspaceLoadError, setWorkspaceLoadError] = useState<string | null>(null);
|
||||||
|
const [organizationLoadError, setOrganizationLoadError] = useState<string | null>(null);
|
||||||
|
const [openCreateProjectModal, setOpenCreateProjectModal] = useState(false);
|
||||||
|
const [openCreateOrganizationModal, setOpenCreateOrganizationModal] = useState(false);
|
||||||
|
const [openProjectLimitModal, setOpenProjectLimitModal] = useState(false);
|
||||||
|
|
||||||
|
const renderSwitcherError = (error: string, onRetry: () => void, retryLabel: string) => (
|
||||||
|
<div className="px-2 py-4">
|
||||||
|
<p className="mb-2 text-sm text-red-600">{error}</p>
|
||||||
|
<button onClick={onRetry} className="text-xs text-slate-600 underline hover:text-slate-800">
|
||||||
|
{retryLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectSettings = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
label: t("common.general"),
|
||||||
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "look",
|
||||||
|
label: t("common.look_and_feel"),
|
||||||
|
href: `/environments/${environment.id}/workspace/look`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "app-connection",
|
||||||
|
label: t("common.website_and_app_connection"),
|
||||||
|
href: `/environments/${environment.id}/workspace/app-connection`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "integrations",
|
||||||
|
label: t("common.integrations"),
|
||||||
|
href: `/environments/${environment.id}/workspace/integrations`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "teams",
|
||||||
|
label: t("common.team_access"),
|
||||||
|
href: `/environments/${environment.id}/workspace/teams`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "languages",
|
||||||
|
label: t("common.survey_languages"),
|
||||||
|
href: `/environments/${environment.id}/workspace/languages`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
label: t("common.tags"),
|
||||||
|
href: `/environments/${environment.id}/workspace/tags`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const organizationSettings = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
label: t("common.general"),
|
||||||
|
href: `/environments/${environment.id}/settings/general`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "teams",
|
||||||
|
label: t("common.members_and_teams"),
|
||||||
|
href: `/environments/${environment.id}/settings/teams`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "api-keys",
|
||||||
|
label: t("common.api_keys"),
|
||||||
|
href: `/environments/${environment.id}/settings/api-keys`,
|
||||||
|
hidden: !isOwnerOrManager,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${environment.id}/settings/domain`,
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "billing",
|
||||||
|
label: t("common.billing"),
|
||||||
|
href: `/environments/${environment.id}/settings/billing`,
|
||||||
|
hidden: !isFormbricksCloud,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "enterprise",
|
||||||
|
label: t("common.enterprise_license"),
|
||||||
|
href: `/environments/${environment.id}/settings/enterprise`,
|
||||||
|
hidden: isFormbricksCloud || isMember,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const loadProjects = useCallback(async () => {
|
||||||
|
setIsLoadingProjects(true);
|
||||||
|
setWorkspaceLoadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getProjectsForSwitcherAction({ organizationId: organization.id });
|
||||||
|
if (result?.data) {
|
||||||
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setProjects(sorted);
|
||||||
|
} else {
|
||||||
|
setWorkspaceLoadError(getFormattedErrorMessage(result) || t("common.failed_to_load_workspaces"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const formattedError =
|
||||||
|
typeof error === "object" && error !== null
|
||||||
|
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
||||||
|
: "";
|
||||||
|
setWorkspaceLoadError(
|
||||||
|
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_workspaces"))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingProjects(false);
|
||||||
|
setHasInitializedProjects(true);
|
||||||
|
}
|
||||||
|
}, [organization.id, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWorkspaceDropdownOpen || projects.length > 0 || isLoadingProjects || workspaceLoadError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProjects();
|
||||||
|
}, [isWorkspaceDropdownOpen, projects.length, isLoadingProjects, workspaceLoadError, loadProjects]);
|
||||||
|
|
||||||
|
const loadOrganizations = useCallback(async () => {
|
||||||
|
setIsLoadingOrganizations(true);
|
||||||
|
setOrganizationLoadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getOrganizationsForSwitcherAction({ organizationId: organization.id });
|
||||||
|
if (result?.data) {
|
||||||
|
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
setOrganizations(sorted);
|
||||||
|
} else {
|
||||||
|
setOrganizationLoadError(
|
||||||
|
getFormattedErrorMessage(result) || t("common.failed_to_load_organizations")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const formattedError =
|
||||||
|
typeof error === "object" && error !== null
|
||||||
|
? getFormattedErrorMessage(error as { serverError?: string; validationErrors?: unknown })
|
||||||
|
: "";
|
||||||
|
setOrganizationLoadError(
|
||||||
|
formattedError || (error instanceof Error ? error.message : t("common.failed_to_load_organizations"))
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOrganizations(false);
|
||||||
|
}
|
||||||
|
}, [organization.id, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isOrganizationDropdownOpen ||
|
||||||
|
organizations.length > 0 ||
|
||||||
|
isLoadingOrganizations ||
|
||||||
|
organizationLoadError
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadOrganizations();
|
||||||
|
}, [
|
||||||
|
isOrganizationDropdownOpen,
|
||||||
|
organizations.length,
|
||||||
|
isLoadingOrganizations,
|
||||||
|
organizationLoadError,
|
||||||
|
loadOrganizations,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
const res = await getLatestStableFbReleaseAction();
|
const res = await getLatestStableFbReleaseAction();
|
||||||
@@ -159,7 +390,93 @@ export const MainNavigation = ({
|
|||||||
if (isOwnerOrManager) loadReleases();
|
if (isOwnerOrManager) loadReleases();
|
||||||
}, [isOwnerOrManager]);
|
}, [isOwnerOrManager]);
|
||||||
|
|
||||||
const mainNavigationLink = `/environments/${environment.id}/${isBilling ? "settings/billing/" : "surveys/"}`;
|
const trialDaysRemaining = useMemo(() => {
|
||||||
|
if (!isFormbricksCloud || organization.billing?.stripe?.subscriptionStatus !== "trialing") return null;
|
||||||
|
const trialEnd = organization.billing.stripe.trialEnd;
|
||||||
|
if (!trialEnd) return null;
|
||||||
|
const ts = new Date(trialEnd).getTime();
|
||||||
|
if (!Number.isFinite(ts)) return null;
|
||||||
|
const msPerDay = 86_400_000;
|
||||||
|
return Math.ceil((ts - Date.now()) / msPerDay);
|
||||||
|
}, [
|
||||||
|
isFormbricksCloud,
|
||||||
|
organization.billing?.stripe?.subscriptionStatus,
|
||||||
|
organization.billing?.stripe?.trialEnd,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mainNavigationLink = isBilling
|
||||||
|
? getBillingFallbackPath(environment.id, isFormbricksCloud)
|
||||||
|
: `/environments/${environment.id}/surveys/`;
|
||||||
|
|
||||||
|
const handleProjectChange = (projectId: string) => {
|
||||||
|
if (projectId === project.id) return;
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/workspaces/${projectId}/`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOrganizationChange = (organizationId: string) => {
|
||||||
|
if (organizationId === organization.id) return;
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(`/organizations/${organizationId}/`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSettingNavigation = (href: string) => {
|
||||||
|
startTransition(() => {
|
||||||
|
router.push(href);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProjectCreate = () => {
|
||||||
|
if (!hasInitializedProjects || isLoadingProjects) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (projects.length >= organizationProjectsLimit) {
|
||||||
|
setOpenProjectLimitModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenCreateProjectModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectLimitModalButtons = (): [ModalButton, ModalButton] => {
|
||||||
|
if (isFormbricksCloud) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: t("environments.settings.billing.upgrade"),
|
||||||
|
href: `/environments/${environment.id}/settings/billing`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
onClick: () => setOpenProjectLimitModal(false),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
text: t("environments.settings.billing.upgrade"),
|
||||||
|
href: isLicenseActive
|
||||||
|
? `/environments/${environment.id}/settings/enterprise`
|
||||||
|
: "https://formbricks.com/upgrade-self-hosted-license",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t("common.cancel"),
|
||||||
|
onClick: () => setOpenProjectLimitModal(false),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const switcherTriggerClasses = cn(
|
||||||
|
"w-full border-t px-3 py-3 text-left transition-colors duration-200 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-500 focus-visible:ring-inset",
|
||||||
|
isCollapsed ? "flex items-center justify-center" : ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const switcherIconClasses =
|
||||||
|
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||||
|
const isInitialProjectsLoading = isWorkspaceDropdownOpen && !hasInitializedProjects && !workspaceLoadError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -188,7 +505,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
@@ -199,24 +516,24 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Nav Switch */}
|
{/* Main Nav Switch */}
|
||||||
{!isBilling && (
|
<ul>
|
||||||
<ul>
|
{mainNavigation.map(
|
||||||
{mainNavigation.map(
|
(item) =>
|
||||||
(item) =>
|
!item.isHidden && (
|
||||||
!item.isHidden && (
|
<NavigationLink
|
||||||
<NavigationLink
|
key={item.name}
|
||||||
key={item.name}
|
href={item.href}
|
||||||
href={item.href}
|
isActive={item.isActive}
|
||||||
isActive={item.isActive}
|
isCollapsed={isCollapsed}
|
||||||
isCollapsed={isCollapsed}
|
isTextVisible={isTextVisible}
|
||||||
isTextVisible={isTextVisible}
|
disabled={item.disabled}
|
||||||
linkText={item.name}>
|
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||||
<item.icon strokeWidth={1.5} />
|
linkText={item.name}>
|
||||||
</NavigationLink>
|
<item.icon strokeWidth={1.5} />
|
||||||
)
|
</NavigationLink>
|
||||||
)}
|
)
|
||||||
</ul>
|
)}
|
||||||
)}
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -233,38 +550,217 @@ export const MainNavigation = ({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User Switch */}
|
{/* Trial Days Remaining */}
|
||||||
<div className="flex items-center">
|
{!isCollapsed && isFormbricksCloud && trialDaysRemaining !== null && (
|
||||||
|
<Link href={`/environments/${environment.id}/settings/billing`} className="m-2 block">
|
||||||
|
<TrialAlert trialDaysRemaining={trialDaysRemaining} size="small" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<DropdownMenu onOpenChange={setIsWorkspaceDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger asChild id="workspaceDropdownTrigger" className={switcherTriggerClasses}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isCollapsed ? t("common.change_workspace") : undefined}
|
||||||
|
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||||
|
<span className={switcherIconClasses}>
|
||||||
|
<FoldersIcon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
{!isCollapsed && !isTextVisible && (
|
||||||
|
<>
|
||||||
|
<div className="grow overflow-hidden">
|
||||||
|
<p className="truncate text-sm font-bold text-slate-700">{project.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{t("common.workspace")}</p>
|
||||||
|
</div>
|
||||||
|
{isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.change_workspace")}
|
||||||
|
</div>
|
||||||
|
{(isLoadingProjects || isInitialProjectsLoading) && (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingProjects &&
|
||||||
|
!isInitialProjectsLoading &&
|
||||||
|
workspaceLoadError &&
|
||||||
|
renderSwitcherError(
|
||||||
|
workspaceLoadError,
|
||||||
|
() => {
|
||||||
|
setWorkspaceLoadError(null);
|
||||||
|
setProjects([]);
|
||||||
|
},
|
||||||
|
t("common.try_again")
|
||||||
|
)}
|
||||||
|
{!isLoadingProjects && !isInitialProjectsLoading && !workspaceLoadError && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
|
{projects.map((proj) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={proj.id}
|
||||||
|
checked={proj.id === project.id}
|
||||||
|
onClick={() => handleProjectChange(proj.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{proj.name}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isOwnerOrManager && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={handleProjectCreate}
|
||||||
|
className="w-full cursor-pointer justify-between">
|
||||||
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<Cog className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.workspace_configuration")}
|
||||||
|
</div>
|
||||||
|
{projectSettings.map((setting) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={setting.id}
|
||||||
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleSettingNavigation(setting.href)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<DropdownMenu onOpenChange={setIsOrganizationDropdownOpen}>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
asChild
|
||||||
|
id="organizationDropdownTriggerSidebar"
|
||||||
|
className={switcherTriggerClasses}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={isCollapsed ? t("common.change_organization") : undefined}
|
||||||
|
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||||
|
<span className={switcherIconClasses}>
|
||||||
|
<Building2Icon className="h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
{!isCollapsed && !isTextVisible && (
|
||||||
|
<>
|
||||||
|
<div className="grow overflow-hidden">
|
||||||
|
<p className="truncate text-sm font-bold text-slate-700">{organization.name}</p>
|
||||||
|
<p className="text-sm text-slate-500">{t("common.organization")}</p>
|
||||||
|
</div>
|
||||||
|
{isPending && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-slate-600" strokeWidth={1.5} />
|
||||||
|
)}
|
||||||
|
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent side="right" sideOffset={10} alignOffset={5} align="end">
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<Building2Icon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.change_organization")}
|
||||||
|
</div>
|
||||||
|
{isLoadingOrganizations && (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isLoadingOrganizations &&
|
||||||
|
organizationLoadError &&
|
||||||
|
renderSwitcherError(
|
||||||
|
organizationLoadError,
|
||||||
|
() => {
|
||||||
|
setOrganizationLoadError(null);
|
||||||
|
setOrganizations([]);
|
||||||
|
},
|
||||||
|
t("common.try_again")
|
||||||
|
)}
|
||||||
|
{!isLoadingOrganizations && !organizationLoadError && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={org.id}
|
||||||
|
checked={org.id === organization.id}
|
||||||
|
onClick={() => handleOrganizationChange(org.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{org.name}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
{isMultiOrgEnabled && (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
onClick={() => setOpenCreateOrganizationModal(true)}
|
||||||
|
className="w-full cursor-pointer justify-between">
|
||||||
|
<span>{t("common.create_new_organization")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
|
<SettingsIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
|
{t("common.organization_settings")}
|
||||||
|
</div>
|
||||||
|
{organizationSettings.map((setting) => {
|
||||||
|
if (setting.hidden) return null;
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={setting.id}
|
||||||
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleSettingNavigation(setting.href)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
asChild
|
asChild
|
||||||
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={cn(switcherTriggerClasses, "rounded-br-xl")}>
|
||||||
<div
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
"flex cursor-pointer flex-row items-center gap-3",
|
aria-label={isCollapsed ? t("common.account_settings") : undefined}
|
||||||
isCollapsed ? "justify-center px-2" : "px-4"
|
className={cn("flex w-full items-center gap-3", isCollapsed && "justify-center")}>
|
||||||
)}>
|
<span className={switcherIconClasses}>
|
||||||
<ProfileAvatar userId={user.id} />
|
<ProfileAvatar userId={user.id} />
|
||||||
|
</span>
|
||||||
{!isCollapsed && !isTextVisible && (
|
{!isCollapsed && !isTextVisible && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="grow overflow-hidden">
|
||||||
className={cn(isTextVisible ? "opacity-0" : "opacity-100", "grow overflow-hidden")}>
|
|
||||||
<p
|
<p
|
||||||
title={user?.email}
|
title={user?.email}
|
||||||
className={cn(
|
className="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 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 className="text-sm text-slate-500">{t("common.account")}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRightIcon
|
<ChevronRightIcon className="h-4 w-4 shrink-0 text-slate-600" strokeWidth={1.5} />
|
||||||
className={cn("h-5 w-5 shrink-0 text-slate-700 hover:text-slate-500")}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@@ -273,8 +769,6 @@ export const MainNavigation = ({
|
|||||||
sideOffset={10}
|
sideOffset={10}
|
||||||
alignOffset={5}
|
alignOffset={5}
|
||||||
align="end">
|
align="end">
|
||||||
{/* Dropdown Items */}
|
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
href={link.href}
|
href={link.href}
|
||||||
@@ -288,7 +782,6 @@ export const MainNavigation = ({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{/* Logout */}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const loginUrl = `${publicDomain}/auth/login`;
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
@@ -311,6 +804,28 @@ export const MainNavigation = ({
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
{openProjectLimitModal && (
|
||||||
|
<ProjectLimitModal
|
||||||
|
open={openProjectLimitModal}
|
||||||
|
setOpen={setOpenProjectLimitModal}
|
||||||
|
buttons={projectLimitModalButtons()}
|
||||||
|
projectLimit={organizationProjectsLimit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{openCreateProjectModal && (
|
||||||
|
<CreateProjectModal
|
||||||
|
open={openCreateProjectModal}
|
||||||
|
setOpen={setOpenCreateProjectModal}
|
||||||
|
organizationId={organization.id}
|
||||||
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{openCreateOrganizationModal && (
|
||||||
|
<CreateOrganizationModal
|
||||||
|
open={openCreateOrganizationModal}
|
||||||
|
setOpen={setOpenCreateOrganizationModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
@@ -10,6 +11,8 @@ interface NavigationLinkProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
linkText: string;
|
linkText: string;
|
||||||
isTextVisible: boolean;
|
isTextVisible: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
disabledMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NavigationLink = ({
|
export const NavigationLink = ({
|
||||||
@@ -19,10 +22,34 @@ export const NavigationLink = ({
|
|||||||
children,
|
children,
|
||||||
linkText,
|
linkText,
|
||||||
isTextVisible = true,
|
isTextVisible = true,
|
||||||
|
disabled = false,
|
||||||
|
disabledMessage,
|
||||||
}: NavigationLinkProps) => {
|
}: NavigationLinkProps) => {
|
||||||
|
const tooltipText = disabled ? disabledMessage || linkText : linkText;
|
||||||
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
const activeClass = "bg-slate-50 border-r-4 border-brand-dark font-semibold text-slate-900";
|
||||||
const inactiveClass =
|
const inactiveClass =
|
||||||
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
"hover:bg-slate-50 border-r-4 border-transparent hover:border-slate-300 transition-all duration-150 ease-in-out";
|
||||||
|
const disabledClass = "cursor-not-allowed border-r-4 border-transparent text-slate-400";
|
||||||
|
const getColorClass = (baseClass: string) => {
|
||||||
|
if (disabled) {
|
||||||
|
return disabledClass;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cn(baseClass, isActive ? activeClass : inactiveClass);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collapsedColorClass = getColorClass("text-slate-700 hover:text-slate-900");
|
||||||
|
const expandedColorClass = getColorClass("text-slate-600 hover:text-slate-900");
|
||||||
|
|
||||||
|
const label = (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-2 flex transition-opacity duration-100",
|
||||||
|
isTextVisible ? "opacity-0" : "opacity-100"
|
||||||
|
)}>
|
||||||
|
{linkText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -30,35 +57,37 @@ export const NavigationLink = ({
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<li
|
<li className={cn("mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm", collapsedColorClass)}>
|
||||||
className={cn(
|
{disabled ? (
|
||||||
"mb-1 ml-2 rounded-l-md py-2 pl-2 text-sm text-slate-700 hover:text-slate-900",
|
<div className="flex items-center">{children}</div>
|
||||||
isActive ? activeClass : inactiveClass
|
) : (
|
||||||
)}>
|
<Link href={href}>{children}</Link>
|
||||||
<Link href={href} className="flex items-center">
|
)}
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
</li>
|
</li>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{linkText}</TooltipContent>
|
<TooltipContent side="right">{tooltipText}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
) : (
|
) : (
|
||||||
<li
|
<li className={cn("mb-1 rounded-l-md py-2 pl-5 text-sm", expandedColorClass)}>
|
||||||
className={cn(
|
{disabled ? (
|
||||||
"mb-1 rounded-l-md py-2 pl-5 text-sm text-slate-600 hover:text-slate-900",
|
<Popover>
|
||||||
isActive ? activeClass : inactiveClass
|
<PopoverTrigger asChild>
|
||||||
)}>
|
<div className="flex items-center">
|
||||||
<Link href={href} className="flex items-center">
|
{children}
|
||||||
{children}
|
{label}
|
||||||
<span
|
</div>
|
||||||
className={cn(
|
</PopoverTrigger>
|
||||||
"ml-2 flex transition-opacity duration-100",
|
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||||
isTextVisible ? "opacity-0" : "opacity-100"
|
{disabledMessage || linkText}
|
||||||
)}>
|
</PopoverContent>
|
||||||
{linkText}
|
</Popover>
|
||||||
</span>
|
) : (
|
||||||
</Link>
|
<Link href={href} className="flex items-center">
|
||||||
|
{children}
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ export const TopControlBar = ({
|
|||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
membershipRole,
|
membershipRole,
|
||||||
}: TopControlBarProps) => {
|
}: TopControlBarProps) => {
|
||||||
const { isMember } = getAccessFlags(membershipRole);
|
const { isMember, isBilling } = getAccessFlags(membershipRole);
|
||||||
|
const isMembershipPending = membershipRole === undefined;
|
||||||
const { environment } = useEnvironment();
|
const { environment } = useEnvironment();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +50,8 @@ export const TopControlBar = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
|
isBilling={isBilling}
|
||||||
|
isMembershipPending={isMembershipPending}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||||
import { useOrganization } from "../context/environment-context";
|
import { useOrganization } from "../context/environment-context";
|
||||||
|
|
||||||
interface OrganizationBreadcrumbProps {
|
interface OrganizationBreadcrumbProps {
|
||||||
@@ -35,6 +36,7 @@ interface OrganizationBreadcrumbProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
|
isMembershipPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveOrganizationSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -56,6 +58,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isMember,
|
isMember,
|
||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
|
isMembershipPending,
|
||||||
}: OrganizationBreadcrumbProps) => {
|
}: OrganizationBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
const [isOrganizationDropdownOpen, setIsOrganizationDropdownOpen] = useState(false);
|
||||||
@@ -142,7 +145,10 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "api-keys",
|
id: "api-keys",
|
||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
hidden: !isOwnerOrManager,
|
disabled: isMembershipPending || !isOwnerOrManager,
|
||||||
|
disabledMessage: isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -160,7 +166,11 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
href: `/environments/${currentEnvironmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud || isMember,
|
hidden: isFormbricksCloud,
|
||||||
|
disabled: isMembershipPending || isMember,
|
||||||
|
disabledMessage: isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -242,14 +252,30 @@ export const OrganizationBreadcrumb = ({
|
|||||||
|
|
||||||
{organizationSettings.map((setting) => {
|
{organizationSettings.map((setting) => {
|
||||||
return setting.hidden ? null : (
|
return setting.hidden ? null : (
|
||||||
<DropdownMenuCheckboxItem
|
<div key={setting.id}>
|
||||||
key={setting.id}
|
{setting.disabled ? (
|
||||||
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
<Popover>
|
||||||
hidden={setting.hidden}
|
<PopoverTrigger asChild>
|
||||||
onClick={() => handleSettingChange(setting.href)}
|
<button
|
||||||
className="cursor-pointer">
|
type="button"
|
||||||
{setting.label}
|
aria-disabled="true"
|
||||||
</DropdownMenuCheckboxItem>
|
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||||
|
{setting.label}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||||
|
{setting.disabledMessage}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={isActiveOrganizationSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleSettingChange(setting.href)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ interface ProjectAndOrgSwitchProps {
|
|||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
isOwnerOrManager: boolean;
|
isOwnerOrManager: boolean;
|
||||||
isMember: boolean;
|
isMember: boolean;
|
||||||
|
isBilling: boolean;
|
||||||
|
isMembershipPending: boolean;
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +37,8 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isOwnerOrManager,
|
isOwnerOrManager,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isMember,
|
isMember,
|
||||||
|
isBilling,
|
||||||
|
isMembershipPending,
|
||||||
}: ProjectAndOrgSwitchProps) => {
|
}: ProjectAndOrgSwitchProps) => {
|
||||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||||
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
|
||||||
@@ -50,6 +54,7 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isFormbricksCloud={isFormbricksCloud}
|
isFormbricksCloud={isFormbricksCloud}
|
||||||
isMember={isMember}
|
isMember={isMember}
|
||||||
isOwnerOrManager={isOwnerOrManager}
|
isOwnerOrManager={isOwnerOrManager}
|
||||||
|
isMembershipPending={isMembershipPending}
|
||||||
/>
|
/>
|
||||||
{currentProjectId && currentEnvironmentId && (
|
{currentProjectId && currentEnvironmentId && (
|
||||||
<ProjectBreadcrumb
|
<ProjectBreadcrumb
|
||||||
@@ -63,6 +68,8 @@ export const ProjectAndOrgSwitch = ({
|
|||||||
isLicenseActive={isLicenseActive}
|
isLicenseActive={isLicenseActive}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
|
||||||
|
isBilling={isBilling}
|
||||||
|
isMembershipPending={isMembershipPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{showEnvironmentBreadcrumb && (
|
{showEnvironmentBreadcrumb && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FoldersIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/modules/ui/components/dropdown-menu";
|
} from "@/modules/ui/components/dropdown-menu";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/modules/ui/components/popover";
|
||||||
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
import { ModalButton } from "@/modules/ui/components/upgrade-prompt";
|
||||||
import { useProject } from "../context/environment-context";
|
import { useProject } from "../context/environment-context";
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ interface ProjectBreadcrumbProps {
|
|||||||
currentEnvironmentId: string;
|
currentEnvironmentId: string;
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
isEnvironmentBreadcrumbVisible: boolean;
|
isEnvironmentBreadcrumbVisible: boolean;
|
||||||
|
isBilling: boolean;
|
||||||
|
isMembershipPending: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
@@ -56,6 +59,8 @@ export const ProjectBreadcrumb = ({
|
|||||||
currentEnvironmentId,
|
currentEnvironmentId,
|
||||||
isAccessControlAllowed,
|
isAccessControlAllowed,
|
||||||
isEnvironmentBreadcrumbVisible,
|
isEnvironmentBreadcrumbVisible,
|
||||||
|
isBilling,
|
||||||
|
isMembershipPending,
|
||||||
}: ProjectBreadcrumbProps) => {
|
}: ProjectBreadcrumbProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
const [isProjectDropdownOpen, setIsProjectDropdownOpen] = useState(false);
|
||||||
@@ -134,6 +139,10 @@ export const ProjectBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const areProjectSettingsDisabled = isMembershipPending || isBilling;
|
||||||
|
const projectSettingsDisabledMessage = isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action");
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
@@ -198,7 +207,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
<FoldersIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
@@ -211,7 +220,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<FoldersIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_workspace")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
@@ -247,7 +256,24 @@ export const ProjectBreadcrumb = ({
|
|||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
{isOwnerOrManager && (
|
{isMembershipPending || !isOwnerOrManager ? (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-disabled="true"
|
||||||
|
className="relative flex w-full cursor-not-allowed select-none items-center justify-between rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||||
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||||
|
{isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action")}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
@@ -264,13 +290,30 @@ export const ProjectBreadcrumb = ({
|
|||||||
{t("common.workspace_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<div key={setting.id}>
|
||||||
key={setting.id}
|
{areProjectSettingsDisabled ? (
|
||||||
checked={isActiveProjectSetting(pathname, setting.id)}
|
<Popover>
|
||||||
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
<PopoverTrigger asChild>
|
||||||
className="cursor-pointer">
|
<button
|
||||||
{setting.label}
|
type="button"
|
||||||
</DropdownMenuCheckboxItem>
|
aria-disabled="true"
|
||||||
|
className="relative flex w-full cursor-not-allowed select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm font-medium text-slate-400">
|
||||||
|
{setting.label}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-fit max-w-72 px-3 py-2 text-sm text-slate-700">
|
||||||
|
{projectSettingsDisabledMessage}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
checked={isActiveProjectSetting(pathname, setting.id)}
|
||||||
|
onClick={() => handleProjectSettingsNavigation(setting.id)}
|
||||||
|
className="cursor-pointer">
|
||||||
|
{setting.label}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getBillingFallbackPath } from "@/lib/membership/navigation";
|
||||||
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";
|
||||||
|
|
||||||
const EnvironmentPage = async (props) => {
|
const EnvironmentPage = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
const { session, organization } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
@@ -12,11 +13,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(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
|
||||||
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`);
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const AccountSettingsLayout = async (props) => {
|
const AccountSettingsLayout = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -17,15 +21,15 @@ const AccountSettingsLayout = async (props) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { z } from "zod";
|
|||||||
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
import { ZUserNotificationSettings } from "@formbricks/types/user";
|
||||||
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 { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
|
|
||||||
const ZUpdateNotificationSettingsAction = z.object({
|
const ZUpdateNotificationSettingsAction = z.object({
|
||||||
@@ -12,26 +11,16 @@ const ZUpdateNotificationSettingsAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||||
.schema(ZUpdateNotificationSettingsAction)
|
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const oldObject = await getUser(ctx.user.id);
|
||||||
"user",
|
const result = await updateUser(ctx.user.id, {
|
||||||
async ({
|
notificationSettings: parsedInput.notificationSettings,
|
||||||
ctx,
|
});
|
||||||
parsedInput,
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
}: {
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
ctx: AuthenticatedActionClientCtx;
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
parsedInput: Record<string, any>;
|
return result;
|
||||||
}) => {
|
})
|
||||||
const oldObject = await getUser(ctx.user.id);
|
|
||||||
const result = await updateUser(ctx.user.id, {
|
|
||||||
notificationSettings: parsedInput.notificationSettings,
|
|
||||||
});
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
@@ -16,8 +17,8 @@ const setCompleteNotificationSettings = (
|
|||||||
notificationSettings: TUserNotificationSettings,
|
notificationSettings: TUserNotificationSettings,
|
||||||
memberships: Membership[]
|
memberships: Membership[]
|
||||||
): TUserNotificationSettings => {
|
): TUserNotificationSettings => {
|
||||||
const newNotificationSettings = {
|
const newNotificationSettings: TUserNotificationSettings = {
|
||||||
alert: {},
|
alert: {} as Record<string, boolean>,
|
||||||
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
unsubscribedOrganizationIds: notificationSettings.unsubscribedOrganizationIds || [],
|
||||||
};
|
};
|
||||||
for (const membership of memberships) {
|
for (const membership of memberships) {
|
||||||
@@ -26,7 +27,8 @@ const setCompleteNotificationSettings = (
|
|||||||
for (const environment of project.environments) {
|
for (const environment of project.environments) {
|
||||||
for (const survey of environment.surveys) {
|
for (const survey of environment.surveys) {
|
||||||
newNotificationSettings.alert[survey.id] =
|
newNotificationSettings.alert[survey.id] =
|
||||||
notificationSettings[survey.id]?.responseFinished ||
|
(notificationSettings as unknown as Record<string, Record<string, boolean>>)[survey.id]
|
||||||
|
?.responseFinished ||
|
||||||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
|
||||||
false; // check for legacy notification settings w/o "alerts" key
|
false; // check for legacy notification settings w/o "alerts" key
|
||||||
}
|
}
|
||||||
@@ -136,24 +138,27 @@ const getMemberships = async (userId: string): Promise<Membership[]> => {
|
|||||||
return memberships;
|
return memberships;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: {
|
||||||
|
params: Promise<{ environmentId: string }>;
|
||||||
|
searchParams: Promise<Record<string, string>>;
|
||||||
|
}) => {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
const autoDisableNotificationType = searchParams["type"];
|
const autoDisableNotificationType = searchParams["type"];
|
||||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||||
|
|
||||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!memberships) {
|
if (!memberships) {
|
||||||
throw new Error(t("common.membership_not_found"));
|
throw new ResourceNotFoundError(t("common.membership"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user?.notificationSettings) {
|
if (user?.notificationSettings) {
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ import {
|
|||||||
getIsEmailUnique,
|
getIsEmailUnique,
|
||||||
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, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
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";
|
||||||
|
import { requestPasswordReset } from "@/modules/auth/forgot-password/lib/password-reset-service";
|
||||||
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
import { updateBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
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 { sendVerificationNewEmail } from "@/modules/email";
|
||||||
|
|
||||||
function buildUserUpdatePayload(parsedInput: any): TUserUpdateInput {
|
function buildUserUpdatePayload(parsedInput: TUserPersonalInfoUpdateInput): TUserUpdateInput {
|
||||||
return {
|
return {
|
||||||
...(parsedInput.name && { name: parsedInput.name }),
|
...(parsedInput.name && { name: parsedInput.name }),
|
||||||
...(parsedInput.locale && { locale: parsedInput.locale }),
|
...(parsedInput.locale && { locale: parsedInput.locale }),
|
||||||
@@ -63,50 +64,40 @@ async function handleEmailUpdate({
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
|
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "user", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const oldObject = await getUser(ctx.user.id);
|
||||||
"user",
|
let payload = buildUserUpdatePayload(parsedInput);
|
||||||
async ({
|
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: TUserPersonalInfoUpdateInput;
|
|
||||||
}) => {
|
|
||||||
const oldObject = await getUser(ctx.user.id);
|
|
||||||
let payload = buildUserUpdatePayload(parsedInput);
|
|
||||||
payload = await handleEmailUpdate({ ctx, parsedInput, payload });
|
|
||||||
|
|
||||||
// Only proceed with updateUser if we have actual changes to make
|
// Only proceed with updateUser if we have actual changes to make
|
||||||
let newObject = oldObject;
|
let newObject = oldObject;
|
||||||
if (Object.keys(payload).length > 0) {
|
if (Object.keys(payload).length > 0) {
|
||||||
newObject = await updateUser(ctx.user.id, payload);
|
newObject = await updateUser(ctx.user.id, payload);
|
||||||
}
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = newObject;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = newObject;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const resetPasswordAction = authenticatedActionClient.action(
|
export const resetPasswordAction = authenticatedActionClient.action(
|
||||||
withAuditLogging(
|
withAuditLogging("passwordReset", "user", async ({ ctx }) => {
|
||||||
"passwordReset",
|
if (PASSWORD_RESET_DISABLED) {
|
||||||
"user",
|
throw new OperationNotAllowedError("Password reset is disabled");
|
||||||
async ({ ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: undefined }) => {
|
|
||||||
if (ctx.user.identityProvider !== "email") {
|
|
||||||
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendForgotPasswordEmail(ctx.user);
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.userId = ctx.user.id;
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
if (ctx.user.identityProvider !== "email") {
|
||||||
|
throw new OperationNotAllowedError("Password reset is not allowed for this user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestPasswordReset(ctx.user, "profile");
|
||||||
|
|
||||||
|
ctx.auditLoggingCtx.userId = ctx.user.id;
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
|
||||||
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
|
||||||
import { appLanguages } from "@/lib/i18n/utils";
|
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -116,10 +116,14 @@ export const EditProfileDetailsForm = ({
|
|||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
await updateUserAction({
|
const result = await updateUserAction({
|
||||||
...data,
|
...data,
|
||||||
name: data.name.trim(),
|
name: data.name.trim(),
|
||||||
});
|
});
|
||||||
|
if (result?.serverError) {
|
||||||
|
toast.error(getFormattedErrorMessage(result));
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
toast.success(t("environments.settings.profile.profile_updated_successfully"));
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
form.reset(data);
|
form.reset(data);
|
||||||
@@ -198,41 +202,54 @@ export const EditProfileDetailsForm = ({
|
|||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="locale"
|
name="locale"
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem className="mt-4">
|
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
|
||||||
<FormLabel>{t("common.language")}</FormLabel>
|
|
||||||
<FormControl>
|
return (
|
||||||
<DropdownMenu>
|
<FormItem className="mt-4">
|
||||||
<DropdownMenuTrigger asChild>
|
<FormLabel>{t("common.language")}</FormLabel>
|
||||||
<Button
|
<FormControl>
|
||||||
type="button"
|
<DropdownMenu>
|
||||||
variant="ghost"
|
<DropdownMenuTrigger asChild>
|
||||||
className="h-10 w-full border border-slate-300 px-3 text-left">
|
<Button
|
||||||
<div className="flex w-full items-center justify-between">
|
type="button"
|
||||||
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
|
variant="ghost"
|
||||||
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
className="h-10 w-full border border-slate-300 px-3 text-left">
|
||||||
</div>
|
<div className="flex w-full items-center justify-between">
|
||||||
</Button>
|
{selectedLanguage ? (
|
||||||
</DropdownMenuTrigger>
|
<>
|
||||||
<DropdownMenuContent
|
{selectedLanguage.label["en-US"]}
|
||||||
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
|
||||||
align="start">
|
` (${selectedLanguage.label.native})`}
|
||||||
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
</>
|
||||||
{appLanguages.map((lang) => (
|
) : (
|
||||||
<DropdownMenuRadioItem
|
t("common.select")
|
||||||
key={lang.code}
|
)}
|
||||||
value={lang.code}
|
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
|
||||||
className="min-h-8 cursor-pointer">
|
</div>
|
||||||
{lang.label["en-US"]}
|
</Button>
|
||||||
</DropdownMenuRadioItem>
|
</DropdownMenuTrigger>
|
||||||
))}
|
<DropdownMenuContent
|
||||||
</DropdownMenuRadioGroup>
|
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
|
||||||
</DropdownMenuContent>
|
align="start">
|
||||||
</DropdownMenu>
|
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
|
||||||
</FormControl>
|
{sortedAppLanguages.map((lang) => (
|
||||||
<FormError />
|
<DropdownMenuRadioItem
|
||||||
</FormItem>
|
key={lang.code}
|
||||||
)}
|
value={lang.code}
|
||||||
|
className="min-h-8 cursor-pointer">
|
||||||
|
{lang.label["en-US"]}
|
||||||
|
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
|
||||||
|
</DropdownMenuRadioItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuRadioGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</FormControl>
|
||||||
|
<FormError />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isPasswordResetEnabled && (
|
{isPasswordResetEnabled && (
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
|||||||
aria-label="password"
|
aria-label="password"
|
||||||
aria-required="true"
|
aria-required="true"
|
||||||
required
|
required
|
||||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(password) => field.onChange(password)}
|
onChange={(password) => field.onChange(password)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
import { AccountSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(account)/components/AccountSettingsNavbar";
|
||||||
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
import { AccountSecurity } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/AccountSecurity";
|
||||||
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||||
@@ -28,7 +29,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const user = session?.user ? await getUser(session.user.id) : null;
|
const user = session?.user ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
|
||||||
@@ -60,7 +61,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: IS_FORMBRICKS_CLOUD
|
text: IS_FORMBRICKS_CLOUD
|
||||||
? t("common.start_free_trial")
|
? t("common.upgrade_plan")
|
||||||
: t("common.request_trial_license"),
|
: t("common.request_trial_license"),
|
||||||
href: IS_FORMBRICKS_CLOUD
|
href: IS_FORMBRICKS_CLOUD
|
||||||
? `/environments/${params.environmentId}/settings/billing`
|
? `/environments/${params.environmentId}/settings/billing`
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
loading,
|
loading,
|
||||||
}: OrganizationSettingsNavbarProps) => {
|
}: OrganizationSettingsNavbarProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { isMember, isOwner } = getAccessFlags(membershipRole);
|
const { isMember, isOwner, isManager } = getAccessFlags(membershipRole);
|
||||||
const isPricingDisabled = isMember;
|
const isOwnerOrManager = isOwner || isManager;
|
||||||
|
const isMembershipPending = membershipRole === undefined || loading;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
@@ -45,7 +46,10 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
label: t("common.api_keys"),
|
label: t("common.api_keys"),
|
||||||
href: `/environments/${environmentId}/settings/api-keys`,
|
href: `/environments/${environmentId}/settings/api-keys`,
|
||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
hidden: !isOwner,
|
disabled: isMembershipPending || !isOwnerOrManager,
|
||||||
|
disabledMessage: isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "domain",
|
id: "domain",
|
||||||
@@ -58,14 +62,18 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
href: `/environments/${environmentId}/settings/billing`,
|
href: `/environments/${environmentId}/settings/billing`,
|
||||||
hidden: !isFormbricksCloud || loading,
|
hidden: !isFormbricksCloud,
|
||||||
current: pathname?.includes("/billing"),
|
current: pathname?.includes("/billing"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "enterprise",
|
id: "enterprise",
|
||||||
label: t("common.enterprise_license"),
|
label: t("common.enterprise_license"),
|
||||||
href: `/environments/${environmentId}/settings/enterprise`,
|
href: `/environments/${environmentId}/settings/enterprise`,
|
||||||
hidden: isFormbricksCloud || isPricingDisabled,
|
hidden: isFormbricksCloud,
|
||||||
|
disabled: isMembershipPending || isMember,
|
||||||
|
disabledMessage: isMembershipPending
|
||||||
|
? t("common.loading")
|
||||||
|
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||||
current: pathname?.includes("/enterprise"),
|
current: pathname?.includes("/enterprise"),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { AuthenticationError } from "@formbricks/types/errors";
|
||||||
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -25,10 +26,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
const isOwnerOrManager = isManager || isOwner;
|
const isOwnerOrManager = isManager || isOwner;
|
||||||
|
|
||||||
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
const surveys = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
|
import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
|
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||||
|
|
||||||
|
type TFeatureDefinition = {
|
||||||
|
key: TPublicLicenseFeatureKey;
|
||||||
|
labelKey: string;
|
||||||
|
docsUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "contacts",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_contacts"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/contact-management-segments",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "projects",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_projects"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/license",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "whitelabel",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_whitelabel"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/whitelabel-email-follow-ups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "removeBranding",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_remove_branding"),
|
||||||
|
docsUrl:
|
||||||
|
"https://formbricks.com/docs/self-hosting/advanced/enterprise-features/hide-powered-by-formbricks",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "twoFactorAuth",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_two_factor_auth"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/core-features/user-management/two-factor-auth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "sso",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_sso"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/oidc-sso",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "saml",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_saml"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/saml-sso",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "spamProtection",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_spam_protection"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "auditLogs",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_audit_logs"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/audit-logging",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "accessControl",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_access_control"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/self-hosting/advanced/enterprise-features/team-access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "quotas",
|
||||||
|
labelKey: t("environments.settings.enterprise.license_feature_quotas"),
|
||||||
|
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/quota-management",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EnterpriseLicenseFeaturesTableProps {
|
||||||
|
features: TEnterpriseLicenseFeatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EnterpriseLicenseFeaturesTable = ({ features }: EnterpriseLicenseFeaturesTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.enterprise.license_features_table_title")}
|
||||||
|
description={t("environments.settings.enterprise.license_features_table_description")}
|
||||||
|
noPadding>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="hover:bg-white">
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_feature")}</TableHead>
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_access")}</TableHead>
|
||||||
|
<TableHead>{t("environments.settings.enterprise.license_features_table_value")}</TableHead>
|
||||||
|
<TableHead>{t("common.documentation")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{getFeatureDefinitions(t).map((feature) => {
|
||||||
|
const value = features[feature.key];
|
||||||
|
const isEnabled = typeof value === "boolean" ? value : value === null || value > 0;
|
||||||
|
let displayValue: number | string = "—";
|
||||||
|
|
||||||
|
if (typeof value === "number") {
|
||||||
|
displayValue = value;
|
||||||
|
} else if (value === null) {
|
||||||
|
displayValue = t("environments.settings.enterprise.license_features_table_unlimited");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={feature.key} className="hover:bg-white">
|
||||||
|
<TableCell className="font-medium text-slate-900">{t(feature.labelKey)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
type={isEnabled ? "success" : "gray"}
|
||||||
|
size="normal"
|
||||||
|
text={
|
||||||
|
isEnabled
|
||||||
|
? t("environments.settings.enterprise.license_features_table_enabled")
|
||||||
|
: t("environments.settings.enterprise.license_features_table_disabled")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-600">{displayValue}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
href={feature.docsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm font-medium text-slate-700 underline underline-offset-2 hover:text-slate-900">
|
||||||
|
{t("common.read_docs")}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,22 +6,23 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||||
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
import { recheckLicenseAction } from "@/modules/ee/license-check/actions";
|
||||||
|
import type { TLicenseStatus } from "@/modules/ee/license-check/types/enterprise-license";
|
||||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { SettingsCard } from "../../../components/SettingsCard";
|
import { SettingsCard } from "../../../components/SettingsCard";
|
||||||
|
|
||||||
type LicenseStatus = "active" | "expired" | "unreachable" | "invalid_license";
|
|
||||||
|
|
||||||
interface EnterpriseLicenseStatusProps {
|
interface EnterpriseLicenseStatusProps {
|
||||||
status: LicenseStatus;
|
status: TLicenseStatus;
|
||||||
|
lastChecked: Date;
|
||||||
gracePeriodEnd?: Date;
|
gracePeriodEnd?: Date;
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBadgeConfig = (
|
const getBadgeConfig = (
|
||||||
status: LicenseStatus,
|
status: TLicenseStatus,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
): { type: "success" | "error" | "warning" | "gray"; label: string } => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -29,6 +30,11 @@ const getBadgeConfig = (
|
|||||||
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
return { type: "success", label: t("environments.settings.enterprise.license_status_active") };
|
||||||
case "expired":
|
case "expired":
|
||||||
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
return { type: "error", label: t("environments.settings.enterprise.license_status_expired") };
|
||||||
|
case "instance_mismatch":
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
label: t("environments.settings.enterprise.license_status_instance_mismatch"),
|
||||||
|
};
|
||||||
case "unreachable":
|
case "unreachable":
|
||||||
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
return { type: "warning", label: t("environments.settings.enterprise.license_status_unreachable") };
|
||||||
case "invalid_license":
|
case "invalid_license":
|
||||||
@@ -40,10 +46,12 @@ const getBadgeConfig = (
|
|||||||
|
|
||||||
export const EnterpriseLicenseStatus = ({
|
export const EnterpriseLicenseStatus = ({
|
||||||
status,
|
status,
|
||||||
|
lastChecked,
|
||||||
gracePeriodEnd,
|
gracePeriodEnd,
|
||||||
environmentId,
|
environmentId,
|
||||||
}: EnterpriseLicenseStatusProps) => {
|
}: EnterpriseLicenseStatusProps) => {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const locale = i18n.resolvedLanguage ?? i18n.language ?? "en-US";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isRechecking, setIsRechecking] = useState(false);
|
const [isRechecking, setIsRechecking] = useState(false);
|
||||||
|
|
||||||
@@ -59,6 +67,8 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
if (result.data.status === "unreachable") {
|
if (result.data.status === "unreachable") {
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
toast.error(t("environments.settings.enterprise.recheck_license_unreachable"));
|
||||||
|
} else if (result.data.status === "instance_mismatch") {
|
||||||
|
toast.error(t("environments.settings.enterprise.recheck_license_instance_mismatch"));
|
||||||
} else if (result.data.status === "invalid_license") {
|
} else if (result.data.status === "invalid_license") {
|
||||||
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
toast.error(t("environments.settings.enterprise.recheck_license_invalid"));
|
||||||
} else {
|
} else {
|
||||||
@@ -86,7 +96,12 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge type={badgeConfig.type} text={badgeConfig.label} size="normal" className="w-fit" />
|
||||||
|
<span className="text-sm text-slate-500">
|
||||||
|
{t("common.updated_at")} {formatDateTimeForDisplay(new Date(lastChecked), locale)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -112,7 +127,7 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
<Alert variant="warning" size="small">
|
<Alert variant="warning" size="small">
|
||||||
<AlertDescription className="overflow-visible whitespace-normal">
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
{t("environments.settings.enterprise.license_unreachable_grace_period", {
|
||||||
gracePeriodEnd: new Date(gracePeriodEnd).toLocaleDateString(undefined, {
|
gracePeriodEnd: formatDateForDisplay(new Date(gracePeriodEnd), locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -128,6 +143,13 @@ export const EnterpriseLicenseStatus = ({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
{status === "instance_mismatch" && (
|
||||||
|
<Alert variant="error" size="small">
|
||||||
|
<AlertDescription className="overflow-visible whitespace-normal">
|
||||||
|
{t("environments.settings.enterprise.license_instance_mismatch_description")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
<p className="border-t border-slate-100 pt-4 text-sm text-slate-500">
|
||||||
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
{t("environments.settings.enterprise.questions_please_reach_out_to")}{" "}
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
|||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
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 { EnterpriseLicenseFeaturesTable } from "./components/EnterpriseLicenseFeaturesTable";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
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) {
|
||||||
@@ -93,15 +94,19 @@ const Page = async (props) => {
|
|||||||
/>
|
/>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
{hasLicense ? (
|
{hasLicense ? (
|
||||||
<EnterpriseLicenseStatus
|
<>
|
||||||
status={licenseState.status as "active" | "expired" | "unreachable" | "invalid_license"}
|
<EnterpriseLicenseStatus
|
||||||
gracePeriodEnd={
|
status={licenseState.status}
|
||||||
licenseState.status === "unreachable"
|
lastChecked={licenseState.lastChecked}
|
||||||
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
gracePeriodEnd={
|
||||||
: undefined
|
licenseState.status === "unreachable"
|
||||||
}
|
? new Date(licenseState.lastChecked.getTime() + GRACE_PERIOD_MS)
|
||||||
environmentId={params.environmentId}
|
: undefined
|
||||||
/>
|
}
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
/>
|
||||||
|
{licenseState.features && <EnterpriseLicenseFeaturesTable features={licenseState.features} />}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { AuthorizationError, OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
|
import { updateOrganizationAISettingsAction } from "./actions";
|
||||||
|
import { ZOrganizationAISettingsInput } from "./schemas";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
isInstanceAIConfigured: vi.fn(),
|
||||||
|
checkAuthorizationUpdated: vi.fn(),
|
||||||
|
deleteOrganization: vi.fn(),
|
||||||
|
getOrganization: vi.fn(),
|
||||||
|
getIsMultiOrgEnabled: vi.fn(),
|
||||||
|
getTranslate: vi.fn(),
|
||||||
|
updateOrganization: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/utils/action-client", () => ({
|
||||||
|
authenticatedActionClient: {
|
||||||
|
inputSchema: vi.fn(() => ({
|
||||||
|
action: vi.fn((fn) => fn),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||||
|
checkAuthorizationUpdated: mocks.checkAuthorizationUpdated,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
|
deleteOrganization: mocks.deleteOrganization,
|
||||||
|
getOrganization: mocks.getOrganization,
|
||||||
|
updateOrganization: mocks.updateOrganization,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/ai/service", () => ({
|
||||||
|
isInstanceAIConfigured: mocks.isInstanceAIConfigured,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lingodotdev/server", () => ({
|
||||||
|
getTranslate: mocks.getTranslate,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||||
|
withAuditLogging: vi.fn((_eventName, _objectType, fn) => fn),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||||
|
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const organizationId = "cm9gptbhg0000192zceq9ayuc";
|
||||||
|
|
||||||
|
describe("organization AI settings actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mocks.checkAuthorizationUpdated.mockResolvedValue(undefined);
|
||||||
|
mocks.getOrganization.mockResolvedValue({
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: false,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
});
|
||||||
|
mocks.isInstanceAIConfigured.mockReturnValue(true);
|
||||||
|
mocks.getTranslate.mockResolvedValue((key: string, values?: Record<string, string>) =>
|
||||||
|
values ? `${key}:${JSON.stringify(values)}` : key
|
||||||
|
);
|
||||||
|
mocks.updateOrganization.mockResolvedValue({
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
});
|
||||||
|
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts AI toggle updates", () => {
|
||||||
|
expect(
|
||||||
|
ZOrganizationAISettingsInput.parse({
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("passes owner and manager roles to the authorization check and updates organization settings", async () => {
|
||||||
|
const ctx = {
|
||||||
|
user: { id: "user_1", locale: "en-US" },
|
||||||
|
auditLoggingCtx: {},
|
||||||
|
};
|
||||||
|
const parsedInput = {
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await updateOrganizationAISettingsAction({ ctx, parsedInput } as any);
|
||||||
|
|
||||||
|
expect(mocks.checkAuthorizationUpdated).toHaveBeenCalledWith({
|
||||||
|
userId: "user_1",
|
||||||
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
schema: ZOrganizationAISettingsInput,
|
||||||
|
data: parsedInput.data,
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(mocks.getOrganization).toHaveBeenCalledWith(organizationId);
|
||||||
|
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, parsedInput.data);
|
||||||
|
expect(ctx.auditLoggingCtx).toMatchObject({
|
||||||
|
organizationId,
|
||||||
|
oldObject: {
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: false,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
},
|
||||||
|
newObject: {
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("propagates authorization failures so members cannot update AI settings", async () => {
|
||||||
|
mocks.checkAuthorizationUpdated.mockRejectedValueOnce(new AuthorizationError("Not authorized"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateOrganizationAISettingsAction({
|
||||||
|
ctx: {
|
||||||
|
user: { id: "user_member", locale: "en-US" },
|
||||||
|
auditLoggingCtx: {},
|
||||||
|
},
|
||||||
|
parsedInput: {
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
).rejects.toThrow(AuthorizationError);
|
||||||
|
|
||||||
|
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects enabling AI when the instance AI provider is not configured", async () => {
|
||||||
|
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
updateOrganizationAISettingsAction({
|
||||||
|
ctx: {
|
||||||
|
user: { id: "user_owner", locale: "en-US" },
|
||||||
|
auditLoggingCtx: {},
|
||||||
|
},
|
||||||
|
parsedInput: {
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
).rejects.toThrow(OperationNotAllowedError);
|
||||||
|
|
||||||
|
expect(mocks.updateOrganization).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows enabling AI when the instance configuration is valid", async () => {
|
||||||
|
await updateOrganizationAISettingsAction({
|
||||||
|
ctx: {
|
||||||
|
user: { id: "user_owner", locale: "en-US" },
|
||||||
|
auditLoggingCtx: {},
|
||||||
|
},
|
||||||
|
parsedInput: {
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allows disabling AI when the instance configuration later becomes invalid", async () => {
|
||||||
|
mocks.getOrganization.mockResolvedValueOnce({
|
||||||
|
id: organizationId,
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
isAIDataAnalysisEnabled: false,
|
||||||
|
});
|
||||||
|
mocks.isInstanceAIConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
await updateOrganizationAISettingsAction({
|
||||||
|
ctx: {
|
||||||
|
user: { id: "user_owner", locale: "en-US" },
|
||||||
|
auditLoggingCtx: {},
|
||||||
|
},
|
||||||
|
parsedInput: {
|
||||||
|
organizationId,
|
||||||
|
data: {
|
||||||
|
isAISmartToolsEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(mocks.updateOrganization).toHaveBeenCalledWith(organizationId, {
|
||||||
|
isAISmartToolsEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,14 +2,44 @@
|
|||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import type { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||||
|
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||||
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganization, updateOrganization } from "@/lib/organization/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { ZOrganizationAISettingsInput, ZUpdateOrganizationAISettingsAction } from "./schemas";
|
||||||
|
|
||||||
|
async function updateOrganizationAction<T extends z.ZodRawShape>({
|
||||||
|
ctx,
|
||||||
|
organizationId,
|
||||||
|
schema,
|
||||||
|
data,
|
||||||
|
roles,
|
||||||
|
}: {
|
||||||
|
ctx: AuthenticatedActionClientCtx;
|
||||||
|
organizationId: string;
|
||||||
|
schema: z.ZodObject<T>;
|
||||||
|
data: z.infer<z.ZodObject<T>>;
|
||||||
|
roles: TOrganizationRole[];
|
||||||
|
}) {
|
||||||
|
await checkAuthorizationUpdated({
|
||||||
|
userId: ctx.user.id,
|
||||||
|
organizationId,
|
||||||
|
access: [{ type: "organization", schema, data, roles }],
|
||||||
|
});
|
||||||
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
|
const oldObject = await getOrganization(organizationId);
|
||||||
|
const result = await updateOrganization(organizationId, data);
|
||||||
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
|
ctx.auditLoggingCtx.newObject = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
const ZUpdateOrganizationNameAction = z.object({
|
const ZUpdateOrganizationNameAction = z.object({
|
||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
@@ -17,7 +47,7 @@ const ZUpdateOrganizationNameAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateOrganizationNameAction = authenticatedActionClient
|
export const updateOrganizationNameAction = authenticatedActionClient
|
||||||
.schema(ZUpdateOrganizationNameAction)
|
.inputSchema(ZUpdateOrganizationNameAction)
|
||||||
.action(
|
.action(
|
||||||
withAuditLogging(
|
withAuditLogging(
|
||||||
"updated",
|
"updated",
|
||||||
@@ -27,26 +57,104 @@ export const updateOrganizationNameAction = authenticatedActionClient
|
|||||||
parsedInput,
|
parsedInput,
|
||||||
}: {
|
}: {
|
||||||
ctx: AuthenticatedActionClientCtx;
|
ctx: AuthenticatedActionClientCtx;
|
||||||
parsedInput: Record<string, any>;
|
parsedInput: z.infer<typeof ZUpdateOrganizationNameAction>;
|
||||||
}) => {
|
}) =>
|
||||||
await checkAuthorizationUpdated({
|
updateOrganizationAction({
|
||||||
userId: ctx.user.id,
|
ctx,
|
||||||
organizationId: parsedInput.organizationId,
|
organizationId: parsedInput.organizationId,
|
||||||
access: [
|
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
||||||
{
|
data: parsedInput.data,
|
||||||
type: "organization",
|
roles: ["owner"],
|
||||||
schema: ZOrganizationUpdateInput.pick({ name: true }),
|
})
|
||||||
data: parsedInput.data,
|
)
|
||||||
roles: ["owner"],
|
);
|
||||||
},
|
|
||||||
],
|
type TOrganizationAISettings = Pick<
|
||||||
|
NonNullable<Awaited<ReturnType<typeof getOrganization>>>,
|
||||||
|
"isAISmartToolsEnabled" | "isAIDataAnalysisEnabled"
|
||||||
|
>;
|
||||||
|
|
||||||
|
type TResolvedOrganizationAISettings = {
|
||||||
|
smartToolsEnabled: boolean;
|
||||||
|
dataAnalysisEnabled: boolean;
|
||||||
|
isEnablingAnyAISetting: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveOrganizationAISettings = ({
|
||||||
|
data,
|
||||||
|
organization,
|
||||||
|
}: {
|
||||||
|
data: z.infer<typeof ZOrganizationAISettingsInput>;
|
||||||
|
organization: TOrganizationAISettings;
|
||||||
|
}): TResolvedOrganizationAISettings => {
|
||||||
|
const smartToolsEnabled = Object.hasOwn(data, "isAISmartToolsEnabled")
|
||||||
|
? (data.isAISmartToolsEnabled ?? organization.isAISmartToolsEnabled)
|
||||||
|
: organization.isAISmartToolsEnabled;
|
||||||
|
const dataAnalysisEnabled = Object.hasOwn(data, "isAIDataAnalysisEnabled")
|
||||||
|
? (data.isAIDataAnalysisEnabled ?? organization.isAIDataAnalysisEnabled)
|
||||||
|
: organization.isAIDataAnalysisEnabled;
|
||||||
|
|
||||||
|
return {
|
||||||
|
smartToolsEnabled,
|
||||||
|
dataAnalysisEnabled,
|
||||||
|
isEnablingAnyAISetting:
|
||||||
|
(smartToolsEnabled && !organization.isAISmartToolsEnabled) ||
|
||||||
|
(dataAnalysisEnabled && !organization.isAIDataAnalysisEnabled),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertOrganizationAISettingsUpdateAllowed = ({
|
||||||
|
isInstanceAIConfigured,
|
||||||
|
resolvedSettings,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
isInstanceAIConfigured: boolean;
|
||||||
|
resolvedSettings: TResolvedOrganizationAISettings;
|
||||||
|
t: Awaited<ReturnType<typeof getTranslate>>;
|
||||||
|
}) => {
|
||||||
|
if (resolvedSettings.isEnablingAnyAISetting && !isInstanceAIConfigured) {
|
||||||
|
throw new OperationNotAllowedError(t("environments.settings.general.ai_instance_not_configured"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOrganizationAISettingsAction = authenticatedActionClient
|
||||||
|
.inputSchema(ZUpdateOrganizationAISettingsAction)
|
||||||
|
.action(
|
||||||
|
withAuditLogging(
|
||||||
|
"updated",
|
||||||
|
"organization",
|
||||||
|
async ({
|
||||||
|
ctx,
|
||||||
|
parsedInput,
|
||||||
|
}: {
|
||||||
|
ctx: AuthenticatedActionClientCtx;
|
||||||
|
parsedInput: z.infer<typeof ZUpdateOrganizationAISettingsAction>;
|
||||||
|
}) => {
|
||||||
|
const t = await getTranslate(ctx.user.locale);
|
||||||
|
const organization = await getOrganization(parsedInput.organizationId);
|
||||||
|
|
||||||
|
if (!organization) {
|
||||||
|
throw new ResourceNotFoundError("Organization", parsedInput.organizationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedSettings = resolveOrganizationAISettings({
|
||||||
|
data: parsedInput.data,
|
||||||
|
organization,
|
||||||
|
});
|
||||||
|
|
||||||
|
assertOrganizationAISettingsUpdateAllowed({
|
||||||
|
isInstanceAIConfigured: isInstanceAIConfigured(),
|
||||||
|
resolvedSettings,
|
||||||
|
t,
|
||||||
|
});
|
||||||
|
|
||||||
|
return updateOrganizationAction({
|
||||||
|
ctx,
|
||||||
|
organizationId: parsedInput.organizationId,
|
||||||
|
schema: ZOrganizationAISettingsInput,
|
||||||
|
data: parsedInput.data,
|
||||||
|
roles: ["owner", "manager"],
|
||||||
});
|
});
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
|
||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
|
||||||
const result = await updateOrganization(parsedInput.organizationId, parsedInput.data);
|
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
|
||||||
ctx.auditLoggingCtx.newObject = result;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -55,13 +163,15 @@ const ZDeleteOrganizationAction = z.object({
|
|||||||
organizationId: ZId,
|
organizationId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
|
export const deleteOrganizationAction = authenticatedActionClient
|
||||||
withAuditLogging(
|
.inputSchema(ZDeleteOrganizationAction)
|
||||||
"deleted",
|
.action(
|
||||||
"organization",
|
withAuditLogging("deleted", "organization", async ({ ctx, parsedInput }) => {
|
||||||
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
|
if (!isMultiOrgEnabled) {
|
||||||
|
const t = await getTranslate(ctx.user.locale);
|
||||||
|
throw new OperationNotAllowedError(t("environments.settings.general.organization_deletion_disabled"));
|
||||||
|
}
|
||||||
|
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -77,6 +187,5 @@ export const deleteOrganizationAction = authenticatedActionClient.schema(ZDelete
|
|||||||
const oldObject = await getOrganization(parsedInput.organizationId);
|
const oldObject = await getOrganization(parsedInput.organizationId);
|
||||||
ctx.auditLoggingCtx.oldObject = oldObject;
|
ctx.auditLoggingCtx.oldObject = oldObject;
|
||||||
return await deleteOrganization(parsedInput.organizationId);
|
return await deleteOrganization(parsedInput.organizationId);
|
||||||
}
|
})
|
||||||
)
|
);
|
||||||
);
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { updateOrganizationAISettingsAction } from "@/app/(app)/environments/[environmentId]/settings/(organization)/general/actions";
|
||||||
|
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||||
|
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
|
|
||||||
|
interface AISettingsToggleProps {
|
||||||
|
organization: TOrganization;
|
||||||
|
membershipRole?: TOrganizationRole;
|
||||||
|
isInstanceAIConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AISettingsToggle = ({
|
||||||
|
organization,
|
||||||
|
membershipRole,
|
||||||
|
isInstanceAIConfigured,
|
||||||
|
}: Readonly<AISettingsToggleProps>) => {
|
||||||
|
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { isOwner, isManager } = getAccessFlags(membershipRole);
|
||||||
|
const canEdit = isOwner || isManager;
|
||||||
|
const aiEnablementState = getOrganizationAIEnablementState({
|
||||||
|
isInstanceConfigured: isInstanceAIConfigured,
|
||||||
|
});
|
||||||
|
const showInstanceConfigWarning = aiEnablementState.blockReason === "instanceNotConfigured";
|
||||||
|
const isToggleDisabled = loadingField !== null || !canEdit || !aiEnablementState.canEnableFeatures;
|
||||||
|
const aiEnablementBlockedMessage = t("environments.settings.general.ai_instance_not_configured");
|
||||||
|
const displayedSmartToolsValue = getDisplayedOrganizationAISettingValue({
|
||||||
|
currentValue: organization.isAISmartToolsEnabled,
|
||||||
|
isInstanceConfigured: isInstanceAIConfigured,
|
||||||
|
});
|
||||||
|
const displayedDataAnalysisValue = getDisplayedOrganizationAISettingValue({
|
||||||
|
currentValue: organization.isAIDataAnalysisEnabled,
|
||||||
|
isInstanceConfigured: isInstanceAIConfigured,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggle = async (
|
||||||
|
field: "isAISmartToolsEnabled" | "isAIDataAnalysisEnabled",
|
||||||
|
checked: boolean
|
||||||
|
) => {
|
||||||
|
if (checked && !aiEnablementState.canEnableFeatures) {
|
||||||
|
toast.error(aiEnablementBlockedMessage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingField(field);
|
||||||
|
try {
|
||||||
|
const data =
|
||||||
|
field === "isAISmartToolsEnabled"
|
||||||
|
? { isAISmartToolsEnabled: checked }
|
||||||
|
: { isAIDataAnalysisEnabled: checked };
|
||||||
|
const response = await updateOrganizationAISettingsAction({
|
||||||
|
organizationId: organization.id,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.data) {
|
||||||
|
toast.success(t("environments.settings.general.ai_settings_updated_successfully"));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
toast.error(getFormattedErrorMessage(response));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : t("common.something_went_wrong_please_try_again"));
|
||||||
|
} finally {
|
||||||
|
setLoadingField(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{showInstanceConfigWarning && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>{aiEnablementBlockedMessage}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AdvancedOptionToggle
|
||||||
|
isChecked={displayedSmartToolsValue}
|
||||||
|
onToggle={(checked) => handleToggle("isAISmartToolsEnabled", checked)}
|
||||||
|
htmlId="ai-smart-tools-toggle"
|
||||||
|
title={t("environments.settings.general.ai_smart_tools_enabled")}
|
||||||
|
description={t("environments.settings.general.ai_smart_tools_enabled_description")}
|
||||||
|
disabled={isToggleDisabled}
|
||||||
|
customContainerClass="px-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdvancedOptionToggle
|
||||||
|
isChecked={displayedDataAnalysisValue}
|
||||||
|
onToggle={(checked) => handleToggle("isAIDataAnalysisEnabled", checked)}
|
||||||
|
htmlId="ai-data-analysis-toggle"
|
||||||
|
title={t("environments.settings.general.ai_data_analysis_enabled")}
|
||||||
|
description={t("environments.settings.general.ai_data_analysis_enabled_description")}
|
||||||
|
disabled={isToggleDisabled}
|
||||||
|
customContainerClass="px-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!canEdit && (
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -107,7 +107,7 @@ const DeleteOrganizationModal = ({
|
|||||||
}: DeleteOrganizationModalProps) => {
|
}: DeleteOrganizationModalProps) => {
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleInputChange = (e) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
setInputValue(e.target.value);
|
setInputValue(e.target.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const EditOrganizationNameForm = ({ organization, membershipRole }: EditO
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(`Error: ${err.message}`);
|
toast.error(`Error: ${err instanceof Error ? err.message : "Unknown error occurred"}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||||
|
import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
@@ -11,6 +12,7 @@ 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 packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
import { SettingsCard } from "../../components/SettingsCard";
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { AISettingsToggle } from "./components/AISettingsToggle";
|
||||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||||
import { SecurityListTip } from "./components/SecurityListTip";
|
import { SecurityListTip } from "./components/SecurityListTip";
|
||||||
@@ -26,7 +28,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||||
|
|
||||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.billing.plan);
|
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||||
|
|
||||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||||
const currentUserRole = currentUserMembership?.role;
|
const currentUserRole = currentUserMembership?.role;
|
||||||
@@ -60,6 +62,15 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
membershipRole={currentUserMembership?.role}
|
membershipRole={currentUserMembership?.role}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.general.ai_enabled")}
|
||||||
|
description={t("environments.settings.general.ai_enabled_description")}>
|
||||||
|
<AISettingsToggle
|
||||||
|
organization={organization}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
isInstanceAIConfigured={isInstanceAIConfigured()}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
<EmailCustomizationSettings
|
<EmailCustomizationSettings
|
||||||
organization={organization}
|
organization={organization}
|
||||||
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
hasWhiteLabelPermission={hasWhiteLabelPermission}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { ZOrganizationUpdateInput } from "@formbricks/types/organizations";
|
||||||
|
|
||||||
|
export const ZOrganizationAISettingsInput = ZOrganizationUpdateInput.pick({
|
||||||
|
isAISmartToolsEnabled: true,
|
||||||
|
isAIDataAnalysisEnabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZUpdateOrganizationAISettingsAction = z.object({
|
||||||
|
organizationId: ZId,
|
||||||
|
data: ZOrganizationAISettingsInput,
|
||||||
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
const Layout = async (props) => {
|
const Layout = async (props: { params: Promise<{ environmentId: string }>; children: React.ReactNode }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
@@ -17,15 +18,15 @@ const Layout = async (props) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.workspace_not_found"));
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(t("common.session_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export const SettingsTitle = ({ title }) => {
|
export const SettingsTitle = ({ title }: { title: string }) => {
|
||||||
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
return <h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">{title}</h2>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
return redirect(`/environments/${params.environmentId}/settings/profile`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponsesAction = authenticatedActionClient
|
export const getResponsesAction = authenticatedActionClient
|
||||||
.schema(ZGetResponsesAction)
|
.inputSchema(ZGetResponsesAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getSurveySummaryAction = authenticatedActionClient
|
export const getSurveySummaryAction = authenticatedActionClient
|
||||||
.schema(ZGetSurveySummaryAction)
|
.inputSchema(ZGetSurveySummaryAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getResponseCountAction = authenticatedActionClient
|
export const getResponseCountAction = authenticatedActionClient
|
||||||
.schema(ZGetResponseCountAction)
|
.inputSchema(ZGetResponseCountAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
|
|||||||
|
|
||||||
const ZGetDisplaysWithContactAction = z.object({
|
const ZGetDisplaysWithContactAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
limit: z.number().int().min(1).max(100),
|
limit: z.int().min(1).max(100),
|
||||||
offset: z.number().int().nonnegative(),
|
offset: z.int().nonnegative(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||||
.schema(ZGetDisplaysWithContactAction)
|
.inputSchema(ZGetDisplaysWithContactAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
|
|||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
const survey = await getSurvey(params.surveyId);
|
const survey = await getSurvey(params.surveyId);
|
||||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
if (session) {
|
if (session) {
|
||||||
return {
|
return {
|
||||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -25,7 +27,7 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SurveyLayout = async ({ children }) => {
|
const SurveyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||||
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
return <ResponseFilterProvider>{children}</ResponseFilterProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surv
|
|||||||
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
|
||||||
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
|
||||||
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
import { downloadResponsesFile } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/utils";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import {
|
import {
|
||||||
@@ -96,8 +97,8 @@ export const ResponseTable = ({
|
|||||||
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
const showQuotasColumn = isQuotasAllowed && quotas.length > 0;
|
||||||
// Generate columns
|
// Generate columns
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, t, showQuotasColumn),
|
() => generateResponseTableColumns(survey, isExpanded ?? false, isReadOnly, locale, t, showQuotasColumn),
|
||||||
[survey, isExpanded, isReadOnly, t, showQuotasColumn]
|
[survey, isExpanded, isReadOnly, locale, t, showQuotasColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save settings to localStorage when they change
|
// Save settings to localStorage when they change
|
||||||
@@ -201,15 +202,21 @@ export const ResponseTable = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
const deleteResponse = async (responseId: string, params?: { decrementQuotas?: boolean }) => {
|
||||||
await deleteResponseAction({ responseId, decrementQuotas: params?.decrementQuotas ?? false });
|
const result = await deleteResponseAction({
|
||||||
|
responseId,
|
||||||
|
decrementQuotas: params?.decrementQuotas ?? false,
|
||||||
|
});
|
||||||
|
if (result?.serverError) {
|
||||||
|
throw new Error(getFormattedErrorMessage(result));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle downloading selected responses
|
// Handle downloading selected responses
|
||||||
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
|
const downloadSelectedRows = async (responseIds: string[], format: "xlsx" | "csv") => {
|
||||||
try {
|
try {
|
||||||
const downloadResponse = await getResponsesDownloadUrlAction({
|
const downloadResponse = await getResponsesDownloadUrlAction({
|
||||||
surveyId: survey.id,
|
surveyId: survey.id,
|
||||||
format: format,
|
format,
|
||||||
filterCriteria: { responseIds },
|
filterCriteria: { responseIds },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -300,7 +307,6 @@ export const ResponseTable = ({
|
|||||||
<DataTableSettingsModal
|
<DataTableSettingsModal
|
||||||
open={isTableSettingsModalOpen}
|
open={isTableSettingsModalOpen}
|
||||||
setOpen={setIsTableSettingsModalOpen}
|
setOpen={setIsTableSettingsModalOpen}
|
||||||
survey={survey}
|
|
||||||
table={table}
|
table={table}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
handleDragEnd={handleDragEnd}
|
handleDragEnd={handleDragEnd}
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { TFunction } from "i18next";
|
|||||||
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 { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
import { extractChoiceIdsFromResponse } from "@/lib/response/utils";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
import { formatDateTimeForDisplay } 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 { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -34,6 +35,7 @@ const getElementColumnsData = (
|
|||||||
element: TSurveyElement,
|
element: TSurveyElement,
|
||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
|
locale: TUserLocale,
|
||||||
t: TFunction
|
t: TFunction
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
const ELEMENTS_ICON_MAP = getElementIconMap(t);
|
||||||
@@ -41,7 +43,7 @@ const getElementColumnsData = (
|
|||||||
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
const contactInfoFields = ["firstName", "lastName", "email", "phone", "company"];
|
||||||
|
|
||||||
// Helper function to create consistent column headers
|
// Helper function to create consistent column headers
|
||||||
const createElementHeader = (elementType: string, headline: string, suffix?: string) => {
|
const createElementHeader = (elementType: TSurveyElementTypeEnum, headline: string, suffix?: string) => {
|
||||||
const title = suffix ? `${headline} - ${suffix}` : headline;
|
const title = suffix ? `${headline} - ${suffix}` : headline;
|
||||||
const ElementHeader = () => (
|
const ElementHeader = () => (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -167,6 +169,7 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
|
locale={locale}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -218,6 +221,7 @@ const getElementColumnsData = (
|
|||||||
survey={survey}
|
survey={survey}
|
||||||
responseData={responseValue}
|
responseData={responseValue}
|
||||||
language={language}
|
language={language}
|
||||||
|
locale={locale}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
showId={false}
|
showId={false}
|
||||||
/>
|
/>
|
||||||
@@ -232,7 +236,7 @@ const getMetadataColumnsData = (t: TFunction): ColumnDef<TResponseTableData>[] =
|
|||||||
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
const metadataColumns: ColumnDef<TResponseTableData>[] = [];
|
||||||
|
|
||||||
METADATA_FIELDS.forEach((label) => {
|
METADATA_FIELDS.forEach((label) => {
|
||||||
const IconComponent = COLUMNS_ICON_MAP[label];
|
const IconComponent = COLUMNS_ICON_MAP[label as keyof typeof COLUMNS_ICON_MAP];
|
||||||
|
|
||||||
metadataColumns.push({
|
metadataColumns.push({
|
||||||
accessorKey: "METADATA_" + label,
|
accessorKey: "METADATA_" + label,
|
||||||
@@ -259,11 +263,14 @@ export const generateResponseTableColumns = (
|
|||||||
survey: TSurvey,
|
survey: TSurvey,
|
||||||
isExpanded: boolean,
|
isExpanded: boolean,
|
||||||
isReadOnly: boolean,
|
isReadOnly: boolean,
|
||||||
|
locale: TUserLocale,
|
||||||
t: TFunction,
|
t: TFunction,
|
||||||
showQuotasColumn: boolean
|
showQuotasColumn: boolean
|
||||||
): ColumnDef<TResponseTableData>[] => {
|
): ColumnDef<TResponseTableData>[] => {
|
||||||
const elements = getElementsFromBlocks(survey.blocks);
|
const elements = getElementsFromBlocks(survey.blocks);
|
||||||
const elementColumns = elements.flatMap((element) => getElementColumnsData(element, survey, isExpanded, t));
|
const elementColumns = elements.flatMap((element) =>
|
||||||
|
getElementColumnsData(element, survey, isExpanded, locale, t)
|
||||||
|
);
|
||||||
|
|
||||||
const dateColumn: ColumnDef<TResponseTableData> = {
|
const dateColumn: ColumnDef<TResponseTableData> = {
|
||||||
accessorKey: "createdAt",
|
accessorKey: "createdAt",
|
||||||
@@ -271,7 +278,7 @@ export const generateResponseTableColumns = (
|
|||||||
size: 200,
|
size: 200,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const date = new Date(row.original.createdAt);
|
const date = new Date(row.original.createdAt);
|
||||||
return <p className="text-slate-900">{getFormattedDateTimeString(date)}</p>;
|
return <p className="text-slate-900">{formatDateTimeForDisplay(date, locale)}</p>;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { TFunction } from "i18next";
|
||||||
import {
|
import {
|
||||||
AirplayIcon,
|
AirplayIcon,
|
||||||
ArrowUpFromDotIcon,
|
ArrowUpFromDotIcon,
|
||||||
@@ -38,7 +39,7 @@ describe("utils", () => {
|
|||||||
"environments.surveys.responses.source": "Source",
|
"environments.surveys.responses.source": "Source",
|
||||||
};
|
};
|
||||||
return translations[key] || key;
|
return translations[key] || key;
|
||||||
});
|
}) as unknown as TFunction;
|
||||||
|
|
||||||
describe("getAddressFieldLabel", () => {
|
describe("getAddressFieldLabel", () => {
|
||||||
test("returns correct label for addressLine1", () => {
|
test("returns correct label for addressLine1", () => {
|
||||||
|
|||||||
@@ -80,9 +80,24 @@ export const COLUMNS_ICON_MAP = {
|
|||||||
const userAgentFields = ["browser", "os", "device"];
|
const userAgentFields = ["browser", "os", "device"];
|
||||||
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
export const METADATA_FIELDS = ["action", "country", ...userAgentFields, "source", "url"];
|
||||||
|
|
||||||
export const getMetadataValue = (meta: TResponseMeta, label: string) => {
|
export const getMetadataValue = (
|
||||||
if (userAgentFields.includes(label)) {
|
meta: TResponseMeta,
|
||||||
return meta.userAgent?.[label];
|
label: (typeof METADATA_FIELDS)[number]
|
||||||
|
): string | undefined => {
|
||||||
|
switch (label) {
|
||||||
|
case "browser":
|
||||||
|
return meta.userAgent?.browser;
|
||||||
|
case "os":
|
||||||
|
return meta.userAgent?.os;
|
||||||
|
case "device":
|
||||||
|
return meta.userAgent?.device;
|
||||||
|
case "action":
|
||||||
|
return meta.action;
|
||||||
|
case "country":
|
||||||
|
return meta.country;
|
||||||
|
case "source":
|
||||||
|
return meta.source;
|
||||||
|
case "url":
|
||||||
|
return meta.url;
|
||||||
}
|
}
|
||||||
return meta[label];
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
import { ResponsePage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -7,7 +8,6 @@ import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service
|
|||||||
import { getSurvey } from "@/lib/survey/service";
|
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 { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
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, getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -17,31 +17,30 @@ 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";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
const { session, environment, organization, isReadOnly } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [survey, user, tags, isContactsEnabled, responseCount, locale] = await Promise.all([
|
const [survey, user, tags, isContactsEnabled, responseCount] = await Promise.all([
|
||||||
getSurvey(params.surveyId),
|
getSurvey(params.surveyId),
|
||||||
getUser(session.user.id),
|
getUser(session.user.id),
|
||||||
getTagsByEnvironmentId(params.environmentId),
|
getTagsByEnvironmentId(params.environmentId),
|
||||||
getIsContactsEnabled(),
|
getIsContactsEnabled(organization.id),
|
||||||
getResponseCountBySurveyId(params.surveyId),
|
getResponseCountBySurveyId(params.surveyId),
|
||||||
findMatchingLocale(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error(t("common.survey_not_found"));
|
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!organization) {
|
if (!organization) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
const segments = isContactsEnabled ? await getSegments(params.environmentId) : [];
|
||||||
@@ -50,10 +49,10 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
const organizationBilling = await getOrganizationBilling(organization.id);
|
const organizationBilling = await getOrganizationBilling(organization.id);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), organization.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
const isQuotasAllowed = await getIsQuotasEnabled(organization.id);
|
||||||
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
const quotas = isQuotasAllowed ? await getQuotas(survey.id) : [];
|
||||||
|
|
||||||
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
// Fetch initial responses on the server to prevent duplicate client-side fetch
|
||||||
@@ -86,7 +85,7 @@ const Page = async (props) => {
|
|||||||
environmentTags={tags}
|
environmentTags={tags}
|
||||||
user={user}
|
user={user}
|
||||||
responsesPerPage={RESPONSES_PER_PAGE}
|
responsesPerPage={RESPONSES_PER_PAGE}
|
||||||
locale={locale}
|
locale={user.locale}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
isQuotasAllowed={isQuotasAllowed}
|
isQuotasAllowed={isQuotasAllowed}
|
||||||
quotas={quotas}
|
quotas={quotas}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { getEmailTemplateHtml } from "@/app/(app)/environments/[environmentId]/s
|
|||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
|
||||||
import { convertToCsv } from "@/lib/utils/file-conversion";
|
import { convertToCsv } from "@/lib/utils/file-conversion";
|
||||||
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
|
||||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||||
@@ -22,7 +21,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
||||||
.schema(ZSendEmbedSurveyPreviewEmailAction)
|
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
|
||||||
@@ -65,57 +64,49 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
|
|||||||
|
|
||||||
const ZResetSurveyAction = z.object({
|
const ZResetSurveyAction = z.object({
|
||||||
surveyId: ZId,
|
surveyId: ZId,
|
||||||
organizationId: ZId,
|
|
||||||
projectId: ZId,
|
projectId: ZId,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
|
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
|
||||||
withAuditLogging(
|
withAuditLogging("updated", "survey", async ({ ctx, parsedInput }) => {
|
||||||
"updated",
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
"survey",
|
const projectId = await getProjectIdFromSurveyId(parsedInput.surveyId);
|
||||||
async ({
|
|
||||||
ctx,
|
|
||||||
parsedInput,
|
|
||||||
}: {
|
|
||||||
ctx: AuthenticatedActionClientCtx;
|
|
||||||
parsedInput: z.infer<typeof ZResetSurveyAction>;
|
|
||||||
}) => {
|
|
||||||
await checkAuthorizationUpdated({
|
|
||||||
userId: ctx.user.id,
|
|
||||||
organizationId: parsedInput.organizationId,
|
|
||||||
access: [
|
|
||||||
{
|
|
||||||
type: "organization",
|
|
||||||
roles: ["owner", "manager"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "projectTeam",
|
|
||||||
minPermission: "readWrite",
|
|
||||||
projectId: parsedInput.projectId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
|
await checkAuthorizationUpdated({
|
||||||
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
userId: ctx.user.id,
|
||||||
ctx.auditLoggingCtx.oldObject = null;
|
organizationId,
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
minPermission: "readWrite",
|
||||||
|
projectId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
ctx.auditLoggingCtx.organizationId = organizationId;
|
||||||
parsedInput.surveyId
|
ctx.auditLoggingCtx.surveyId = parsedInput.surveyId;
|
||||||
);
|
ctx.auditLoggingCtx.oldObject = null;
|
||||||
|
|
||||||
ctx.auditLoggingCtx.newObject = {
|
const { deletedResponsesCount, deletedDisplaysCount } = await deleteResponsesAndDisplaysForSurvey(
|
||||||
deletedResponsesCount: deletedResponsesCount,
|
parsedInput.surveyId
|
||||||
deletedDisplaysCount: deletedDisplaysCount,
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
ctx.auditLoggingCtx.newObject = {
|
||||||
success: true,
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
deletedResponsesCount: deletedResponsesCount,
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
deletedDisplaysCount: deletedDisplaysCount,
|
};
|
||||||
};
|
|
||||||
}
|
return {
|
||||||
)
|
success: true,
|
||||||
|
deletedResponsesCount: deletedResponsesCount,
|
||||||
|
deletedDisplaysCount: deletedDisplaysCount,
|
||||||
|
};
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZGetEmailHtmlAction = z.object({
|
const ZGetEmailHtmlAction = z.object({
|
||||||
@@ -123,7 +114,7 @@ const ZGetEmailHtmlAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getEmailHtmlAction = authenticatedActionClient
|
export const getEmailHtmlAction = authenticatedActionClient
|
||||||
.schema(ZGetEmailHtmlAction)
|
.inputSchema(ZGetEmailHtmlAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
@@ -152,9 +143,10 @@ const ZGeneratePersonalLinksAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generatePersonalLinksAction = authenticatedActionClient
|
export const generatePersonalLinksAction = authenticatedActionClient
|
||||||
.schema(ZGeneratePersonalLinksAction)
|
.inputSchema(ZGeneratePersonalLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
const isContactsEnabled = await getIsContactsEnabled();
|
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
if (!isContactsEnabled) {
|
if (!isContactsEnabled) {
|
||||||
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
throw new OperationNotAllowedError("Contacts are not enabled for this environment");
|
||||||
}
|
}
|
||||||
@@ -231,7 +223,7 @@ const ZUpdateSingleUseLinksAction = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const updateSingleUseLinksAction = authenticatedActionClient
|
export const updateSingleUseLinksAction = authenticatedActionClient
|
||||||
.schema(ZUpdateSingleUseLinksAction)
|
.inputSchema(ZUpdateSingleUseLinksAction)
|
||||||
.action(async ({ ctx, parsedInput }) => {
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
await checkAuthorizationUpdated({
|
await checkAuthorizationUpdated({
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.booked.count}{" "}
|
{t("common.count_responses", { count: elementSummary.booked.count })}
|
||||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||||
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.skipped.count}{" "}
|
{t("common.count_responses", { count: elementSummary.skipped.count })}
|
||||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: summaryItem.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="group-hover:opacity-80">
|
<div className="group-hover:opacity-80">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { TSurvey, TSurveyElementSummaryDate } from "@formbricks/types/surveys/ty
|
|||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getContactIdentifier } from "@/lib/utils/contact";
|
import { getContactIdentifier } from "@/lib/utils/contact";
|
||||||
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
|
import { formatStoredDateForDisplay } from "@/lib/utils/date-display";
|
||||||
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
import { PersonAvatar } from "@/modules/ui/components/avatars";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
@@ -32,13 +32,14 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderResponseValue = (value: string) => {
|
const renderResponseValue = (value: string) => {
|
||||||
const parsedDate = new Date(value);
|
const formattedDate = formatStoredDateForDisplay(value, elementSummary.element.format, locale);
|
||||||
|
|
||||||
const formattedDate = isNaN(parsedDate.getTime())
|
return (
|
||||||
? `${t("common.invalid_date")}(${value})`
|
formattedDate ??
|
||||||
: formatDateWithOrdinal(parsedDate);
|
t("common.invalid_date_with_value", {
|
||||||
|
value,
|
||||||
return formattedDate;
|
})
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +60,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
elementSummary.samples.slice(0, visibleResponses).map((response) => (
|
||||||
<div
|
<div
|
||||||
key={response.id}
|
key={response.id}
|
||||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
|
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent">
|
||||||
<div className="pl-4 md:pl-6">
|
<div className="pl-4 md:pl-6">
|
||||||
{response.contact ? (
|
{response.contact ? (
|
||||||
<Link
|
<Link
|
||||||
@@ -84,7 +85,7 @@ export const DateElementSummary = ({ elementSummary, environmentId, survey, loca
|
|||||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||||
{renderResponseValue(response.value)}
|
{renderResponseValue(response.value)}
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 text-slate-500 md:px-6">
|
<div className="px-4 md:px-6">
|
||||||
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
|
|||||||
{showResponses && (
|
{showResponses && (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{additionalInfo}
|
{additionalInfo}
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{elementSummary.responseCount}{" "}
|
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
if (label) {
|
if (label) {
|
||||||
return label;
|
return label;
|
||||||
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
||||||
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
|||||||
)}>
|
)}>
|
||||||
<button
|
<button
|
||||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilter(
|
setFilter(
|
||||||
elementSummary.element.id,
|
elementSummary.element.id,
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
|
|||||||
elementSummary.type === "multipleChoiceMulti" ? (
|
elementSummary.type === "multipleChoiceMulti" ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-full space-x-2">
|
<div className="flex w-full space-x-2">
|
||||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const filter = filters[group];
|
const filter = (filters as Record<string, { comparison: string; values: string | string[] | undefined }>)[
|
||||||
|
group
|
||||||
|
];
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
setFilter(
|
setFilter(
|
||||||
@@ -104,7 +106,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
<TabsContent value="aggregated" className="mt-4">
|
<TabsContent value="aggregated" className="mt-4">
|
||||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||||
<div className="space-y-5 text-sm md:text-base">
|
<div className="space-y-5 text-sm md:text-base">
|
||||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
{(["promoters", "passives", "detractors", "dismissed"] as const).map((group) => (
|
||||||
<button
|
<button
|
||||||
className="w-full cursor-pointer hover:opacity-80"
|
className="w-full cursor-pointer hover:opacity-80"
|
||||||
key={group}
|
key={group}
|
||||||
@@ -123,8 +125,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary[group]?.count}{" "}
|
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
@@ -158,7 +159,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
|||||||
}>
|
}>
|
||||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||||
<div
|
<div
|
||||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||||
style={{
|
style={{
|
||||||
height: `${Math.max(choice.percentage, 2)}%`,
|
height: `${Math.max(choice.percentage, 2)}%`,
|
||||||
opacity,
|
opacity,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
elementSummary.element.allowMulti ? (
|
elementSummary.element.allowMulti ? (
|
||||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||||
<InboxIcon className="mr-2 h-4 w-4" />
|
<InboxIcon className="mr-2 h-4 w-4" />
|
||||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex w-full space-x-2">
|
<div className="flex w-full space-x-2">
|
||||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
{t("common.count_selections", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
)
|
)
|
||||||
}>
|
}>
|
||||||
<div
|
<div
|
||||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||||
style={{ opacity }}
|
style={{ opacity }}
|
||||||
/>
|
/>
|
||||||
</ClickableBarSegment>
|
</ClickableBarSegment>
|
||||||
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
{t("common.count_responses", { count: result.count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||||
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
|||||||
<div className="text flex justify-between px-2">
|
<div className="text flex justify-between px-2">
|
||||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||||
{elementSummary.dismissed.count}{" "}
|
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface SummaryMetadataProps {
|
|||||||
isQuotasAllowed: boolean;
|
isQuotasAllowed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatTime = (ttc) => {
|
const formatTime = (ttc: number) => {
|
||||||
const seconds = ttc / 1000;
|
const seconds = ttc / 1000;
|
||||||
let formattedValue;
|
let formattedValue;
|
||||||
|
|
||||||
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
|
|||||||
label={t("environments.surveys.summary.time_to_complete")}
|
label={t("environments.surveys.summary.time_to_complete")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||||
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
||||||
|
defaultValue: "Average time to complete the survey.",
|
||||||
|
})}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const SummaryPage = ({
|
|||||||
setDisplays(data);
|
setDisplays(data);
|
||||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error);
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
setDisplays([]);
|
setDisplays([]);
|
||||||
setHasMoreDisplays(false);
|
setHasMoreDisplays(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const SurveyAnalysisCTA = ({
|
|||||||
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
const [isResetModalOpen, setIsResetModalOpen] = useState(false);
|
||||||
const [isResetting, setIsResetting] = useState(false);
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
|
||||||
const { organizationId, project } = useEnvironment();
|
const { project } = useEnvironment();
|
||||||
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly);
|
||||||
|
|
||||||
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||||
@@ -128,7 +128,6 @@ export const SurveyAnalysisCTA = ({
|
|||||||
setIsResetting(true);
|
setIsResetting(true);
|
||||||
const result = await resetSurveyAction({
|
const result = await resetSurveyAction({
|
||||||
surveyId: survey.id,
|
surveyId: survey.id,
|
||||||
organizationId: organizationId,
|
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
});
|
});
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
|
|||||||
@@ -75,17 +75,7 @@ export const ShareSurveyModal = ({
|
|||||||
const [showView, setShowView] = useState<ModalView>(modalView);
|
const [showView, setShowView] = useState<ModalView>(modalView);
|
||||||
const { email } = user;
|
const { email } = user;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const linkTabs: {
|
const linkTabs = useMemo(() => {
|
||||||
id: ShareViaType | ShareSettingsType;
|
|
||||||
type: LinkTabsType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
componentType: React.ComponentType<unknown>;
|
|
||||||
componentProps: unknown;
|
|
||||||
disabled?: boolean;
|
|
||||||
}[] = useMemo(() => {
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: ShareViaType.ANON_LINKS,
|
id: ShareViaType.ANON_LINKS,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const createNoCodeConfigType = (t: ReturnType<typeof useTranslation>["t"]) => ({
|
|||||||
pageView: t("environments.actions.page_view"),
|
pageView: t("environments.actions.page_view"),
|
||||||
exitIntent: t("environments.actions.exit_intent"),
|
exitIntent: t("environments.actions.exit_intent"),
|
||||||
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
|
fiftyPercentScroll: t("environments.actions.fifty_percent_scroll"),
|
||||||
|
pageDwell: t("environments.actions.time_on_page"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
|
const formatRecontactDaysString = (days: number, t: ReturnType<typeof useTranslation>["t"]) => {
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
|||||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||||
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
|
||||||
{projectCustomScripts}
|
{projectCustomScripts}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
|||||||
rows={8}
|
rows={8}
|
||||||
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
)}
|
)}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
|
|||||||
@@ -163,9 +163,10 @@ export const PersonalLinksTab = ({
|
|||||||
<UpgradePrompt
|
<UpgradePrompt
|
||||||
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
title={t("environments.surveys.share.personal_links.upgrade_prompt_title")}
|
||||||
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
description={t("environments.surveys.share.personal_links.upgrade_prompt_description")}
|
||||||
|
feature="personal_links"
|
||||||
buttons={[
|
buttons={[
|
||||||
{
|
{
|
||||||
text: isFormbricksCloud ? t("common.start_free_trial") : t("common.request_trial_license"),
|
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||||
href: isFormbricksCloud
|
href: isFormbricksCloud
|
||||||
? `/environments/${environmentId}/settings/billing`
|
? `/environments/${environmentId}/settings/billing`
|
||||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to generate QR code:", error);
|
logger.error(error as Error, "Failed to generate QR code");
|
||||||
setHasError(true);
|
setHasError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -66,7 +66,7 @@ export const QRCodeTab = ({ surveyUrl }: QRCodeTabProps) => {
|
|||||||
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
|
downloadInstance.download({ name: "survey-qr-code", extension: "png" });
|
||||||
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
|
toast.success(t("environments.surveys.summary.qr_code_download_with_start_soon"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Failed to download QR code:", error);
|
logger.error(error as Error, "Failed to download QR code");
|
||||||
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
|
toast.error(t("environments.surveys.summary.qr_code_download_failed"));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import {
|
||||||
|
ShareSettingsType,
|
||||||
|
ShareViaType,
|
||||||
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||||
import { Badge } from "@/modules/ui/components/badge";
|
import { Badge } from "@/modules/ui/components/badge";
|
||||||
|
|
||||||
@@ -13,9 +17,9 @@ interface SuccessViewProps {
|
|||||||
publicDomain: string;
|
publicDomain: string;
|
||||||
setSurveyUrl: (url: string) => void;
|
setSurveyUrl: (url: string) => void;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
tabs: { id: string; label: string; icon: React.ElementType }[];
|
tabs: { id: ShareViaType | ShareSettingsType; label: string; icon: React.ElementType }[];
|
||||||
handleViewChange: (view: string) => void;
|
handleViewChange: (view: "start" | "share") => void;
|
||||||
handleEmbedViewWithTab: (tabId: string) => void;
|
handleEmbedViewWithTab: (tabId: ShareViaType | ShareSettingsType) => void;
|
||||||
isReadOnly: boolean;
|
isReadOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +70,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.use_personal_links")}
|
{t("environments.surveys.summary.use_personal_links")}
|
||||||
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/settings/notifications`}
|
href={`/environments/${environmentId}/settings/notifications`}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
@@ -9,11 +10,11 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const survey = await getSurvey(surveyId);
|
const survey = await getSurvey(surveyId);
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error("Survey not found");
|
throw new ResourceNotFoundError(t("common.survey"), surveyId);
|
||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Workspace not found");
|
throw new ResourceNotFoundError(t("common.workspace"), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ describe("Tests for getQuotasSummary service", () => {
|
|||||||
_count: {
|
_count: {
|
||||||
quotaLinks: 0,
|
quotaLinks: 0,
|
||||||
},
|
},
|
||||||
},
|
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await getQuotasSummary(surveyId);
|
const result = await getQuotasSummary(surveyId);
|
||||||
@@ -120,7 +120,7 @@ describe("Tests for getQuotasSummary service", () => {
|
|||||||
_count: {
|
_count: {
|
||||||
quotaLinks: 0,
|
quotaLinks: 0,
|
||||||
},
|
},
|
||||||
},
|
} as unknown as Awaited<ReturnType<typeof prisma.surveyQuota.findMany>>[number],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await getQuotasSummary(surveyId);
|
const result = await getQuotasSummary(surveyId);
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
|
||||||
import {
|
import {
|
||||||
getElementSummary,
|
getElementSummary,
|
||||||
getResponsesForSummary,
|
getResponsesForSummary,
|
||||||
@@ -44,7 +43,7 @@ vi.mock("@/lib/survey/service", () => ({
|
|||||||
}));
|
}));
|
||||||
vi.mock("@/lib/surveyLogic/utils", () => ({
|
vi.mock("@/lib/surveyLogic/utils", () => ({
|
||||||
evaluateLogic: vi.fn(),
|
evaluateLogic: vi.fn(),
|
||||||
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredQuestionIds: [], calculations: {} })),
|
performActions: vi.fn(() => ({ jumpTarget: undefined, requiredElementIds: [], calculations: {} })),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/lib/utils/validate", () => ({
|
vi.mock("@/lib/utils/validate", () => ({
|
||||||
validateInputs: vi.fn(),
|
validateInputs: vi.fn(),
|
||||||
@@ -165,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculates meta correctly", () => {
|
test("calculates meta correctly", () => {
|
||||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
||||||
expect(meta.displayCount).toBe(10);
|
expect(meta.displayCount).toBe(10);
|
||||||
expect(meta.totalResponses).toBe(3);
|
expect(meta.totalResponses).toBe(3);
|
||||||
expect(meta.startsPercentage).toBe(30);
|
expect(meta.startsPercentage).toBe(30);
|
||||||
@@ -179,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero display count", () => {
|
test("handles zero display count", () => {
|
||||||
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
||||||
expect(meta.startsPercentage).toBe(0);
|
expect(meta.startsPercentage).toBe(0);
|
||||||
expect(meta.completedPercentage).toBe(0);
|
expect(meta.completedPercentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero responses", () => {
|
test("handles zero responses", () => {
|
||||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
||||||
expect(meta.totalResponses).toBe(0);
|
expect(meta.totalResponses).toBe(0);
|
||||||
expect(meta.completedResponses).toBe(0);
|
expect(meta.completedResponses).toBe(0);
|
||||||
expect(meta.dropOffCount).toBe(0);
|
expect(meta.dropOffCount).toBe(0);
|
||||||
@@ -229,12 +228,6 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
vi.mocked(convertFloatTo2Decimal).mockImplementation((num) =>
|
||||||
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
num !== undefined && num !== null ? parseFloat(num.toFixed(2)) : 0
|
||||||
);
|
);
|
||||||
vi.mocked(evaluateLogic).mockReturnValue(false); // Default: no logic triggers
|
|
||||||
vi.mocked(performActions).mockReturnValue({
|
|
||||||
jumpTarget: undefined,
|
|
||||||
requiredElementIds: [],
|
|
||||||
calculations: {},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("calculates dropOff correctly with welcome card disabled", () => {
|
test("calculates dropOff correctly with welcome card disabled", () => {
|
||||||
@@ -246,7 +239,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10 },
|
ttc: { q1: 10, q2: 5 }, // Saw q2 but didn't answer it
|
||||||
finished: false,
|
finished: false,
|
||||||
}, // Dropped at q2
|
}, // Dropped at q2
|
||||||
{
|
{
|
||||||
@@ -269,22 +262,55 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff.length).toBe(2);
|
expect(dropOff.length).toBe(2);
|
||||||
// Q1
|
// Q1: welcome card disabled so impressions = displayCount
|
||||||
expect(dropOff[0].elementId).toBe("q1");
|
expect(dropOff[0].elementId).toBe("q1");
|
||||||
expect(dropOff[0].impressions).toBe(displayCount); // Welcome card disabled, so first question impressions = displayCount
|
expect(dropOff[0].impressions).toBe(displayCount);
|
||||||
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
expect(dropOff[0].dropOffCount).toBe(displayCount - responses.length); // 5 displays - 2 started = 3 dropped before q1
|
||||||
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
expect(dropOff[0].dropOffPercentage).toBe(60); // (3/5)*100
|
||||||
expect(dropOff[0].ttc).toBe(10);
|
expect(dropOff[0].ttc).toBe(10);
|
||||||
|
|
||||||
// Q2
|
// Q2: both responses saw q2 (r1 has ttc for q2, r2 answered q2)
|
||||||
expect(dropOff[1].elementId).toBe("q2");
|
expect(dropOff[1].elementId).toBe("q2");
|
||||||
expect(dropOff[1].impressions).toBe(responses.length); // 2 responses reached q1, so 2 impressions for q2
|
expect(dropOff[1].impressions).toBe(2);
|
||||||
expect(dropOff[1].dropOffCount).toBe(1); // 1 response dropped at q2
|
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||||
expect(dropOff[1].ttc).toBe(10);
|
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles logic jumps", () => {
|
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||||
|
// Welcome card enabled so first element drop-off is NOT overridden by displayCount
|
||||||
|
const surveyWithWelcome: TSurvey = {
|
||||||
|
...surveyWithBlocks,
|
||||||
|
welcomeCard: { enabled: true, headline: { default: "Welcome" } } as unknown as TSurvey["welcomeCard"],
|
||||||
|
};
|
||||||
|
const responses = [
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
data: { q1: "a" },
|
||||||
|
updatedAt: new Date(),
|
||||||
|
contact: null,
|
||||||
|
contactAttributes: {},
|
||||||
|
language: "en",
|
||||||
|
ttc: { q1: 10 }, // Only saw q1, never reached q2
|
||||||
|
finished: false,
|
||||||
|
},
|
||||||
|
] as any;
|
||||||
|
const displayCount = 1;
|
||||||
|
const dropOff = getSurveySummaryDropOff(
|
||||||
|
surveyWithWelcome,
|
||||||
|
getElementsFromBlocks(surveyWithWelcome.blocks),
|
||||||
|
responses,
|
||||||
|
displayCount
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dropOff[0].impressions).toBe(1); // Saw q1
|
||||||
|
expect(dropOff[0].dropOffCount).toBe(1); // Dropped at q1 (last seen element)
|
||||||
|
expect(dropOff[1].impressions).toBe(0); // Never saw q2
|
||||||
|
expect(dropOff[1].dropOffCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles logic jumps — impressions based on actual ttc/data, not logic replay", () => {
|
||||||
|
// Survey with 4 questions across 4 blocks, logic on block2 jumps q2->q4 (skipping q3)
|
||||||
const surveyWithLogic: TSurvey = {
|
const surveyWithLogic: TSurvey = {
|
||||||
...mockBaseSurvey,
|
...mockBaseSurvey,
|
||||||
blocks: [
|
blocks: [
|
||||||
@@ -315,36 +341,6 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
charLimit: { enabled: false },
|
charLimit: { enabled: false },
|
||||||
},
|
},
|
||||||
] as TSurveyElement[],
|
] as TSurveyElement[],
|
||||||
logic: [
|
|
||||||
{
|
|
||||||
id: "logic1",
|
|
||||||
conditions: {
|
|
||||||
id: "condition1",
|
|
||||||
connector: "and" as const,
|
|
||||||
conditions: [
|
|
||||||
{
|
|
||||||
id: "c1",
|
|
||||||
leftOperand: {
|
|
||||||
type: "element" as const,
|
|
||||||
value: "q2",
|
|
||||||
},
|
|
||||||
operator: "equals" as const,
|
|
||||||
rightOperand: {
|
|
||||||
type: "static" as const,
|
|
||||||
value: "b",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
id: "action1",
|
|
||||||
objective: "jumpToBlock" as const,
|
|
||||||
target: "q4",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "block3",
|
id: "block3",
|
||||||
@@ -377,28 +373,21 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
],
|
],
|
||||||
questions: [],
|
questions: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Response where user answered q1, q2, then logic jumped to q4 (skipping q3).
|
||||||
|
// The ttc/data reflects exactly what elements were shown — no logic replay needed.
|
||||||
const responses = [
|
const responses = [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
data: { q1: "a", q2: "b" },
|
data: { q1: "a", q2: "b", q4: "d" },
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
contact: null,
|
contact: null,
|
||||||
contactAttributes: {},
|
contactAttributes: {},
|
||||||
language: "en",
|
language: "en",
|
||||||
ttc: { q1: 10, q2: 10 },
|
ttc: { q1: 10, q2: 10, q4: 10 }, // q3 has no ttc entry — was skipped by logic
|
||||||
finished: false,
|
finished: false,
|
||||||
}, // Jumps from q2 to q4, drops at q4
|
},
|
||||||
];
|
];
|
||||||
vi.mocked(evaluateLogic).mockImplementation((_s, data, _v, _, _l) => {
|
|
||||||
// Simulate logic on q2 triggering
|
|
||||||
return data.q2 === "b";
|
|
||||||
});
|
|
||||||
vi.mocked(performActions).mockImplementation((_s, actions, _d, _v) => {
|
|
||||||
if (actions[0] && "objective" in actions[0] && actions[0].objective === "jumpToBlock") {
|
|
||||||
return { jumpTarget: actions[0].target, requiredElementIds: [], calculations: {} };
|
|
||||||
}
|
|
||||||
return { jumpTarget: undefined, requiredElementIds: [], calculations: {} };
|
|
||||||
});
|
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(
|
const dropOff = getSurveySummaryDropOff(
|
||||||
surveyWithLogic,
|
surveyWithLogic,
|
||||||
@@ -407,11 +396,11 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(dropOff[0].impressions).toBe(1); // q1
|
expect(dropOff[0].impressions).toBe(1); // q1: seen
|
||||||
expect(dropOff[1].impressions).toBe(1); // q2
|
expect(dropOff[1].impressions).toBe(1); // q2: seen
|
||||||
expect(dropOff[2].impressions).toBe(0); // q3 (skipped)
|
expect(dropOff[2].impressions).toBe(0); // q3: skipped by logic (no ttc, no data)
|
||||||
expect(dropOff[3].impressions).toBe(1); // q4 (jumped to)
|
expect(dropOff[3].impressions).toBe(1); // q4: jumped to, seen
|
||||||
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4
|
expect(dropOff[3].dropOffCount).toBe(1); // Dropped at q4 (last seen element, not finished)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -662,17 +651,23 @@ describe("getQuestionSummary", () => {
|
|||||||
expect((summary[0] as any).choices).toHaveLength(3);
|
expect((summary[0] as any).choices).toHaveLength(3);
|
||||||
|
|
||||||
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
// Item 1 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
const item1 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||||
|
);
|
||||||
expect(item1.count).toBe(2);
|
expect(item1.count).toBe(2);
|
||||||
expect(item1.avgRanking).toBe(1.5);
|
expect(item1.avgRanking).toBe(1.5);
|
||||||
|
|
||||||
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
// Item 2 is in position 1 once and position 2 once, so avg ranking should be (1+2)/2 = 1.5
|
||||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
const item2 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||||
|
);
|
||||||
expect(item2.count).toBe(2);
|
expect(item2.count).toBe(2);
|
||||||
expect(item2.avgRanking).toBe(1.5);
|
expect(item2.avgRanking).toBe(1.5);
|
||||||
|
|
||||||
// Item 3 is in position 3 twice, so avg ranking should be 3
|
// Item 3 is in position 3 twice, so avg ranking should be 3
|
||||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
const item3 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||||
|
);
|
||||||
expect(item3.count).toBe(2);
|
expect(item3.count).toBe(2);
|
||||||
expect(item3.avgRanking).toBe(3);
|
expect(item3.avgRanking).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -747,17 +742,23 @@ describe("getQuestionSummary", () => {
|
|||||||
expect(summary[0].responseCount).toBe(1);
|
expect(summary[0].responseCount).toBe(1);
|
||||||
|
|
||||||
// Item 1 is in position 2, so avg ranking should be 2
|
// Item 1 is in position 2, so avg ranking should be 2
|
||||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
const item1 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||||
|
);
|
||||||
expect(item1.count).toBe(1);
|
expect(item1.count).toBe(1);
|
||||||
expect(item1.avgRanking).toBe(2);
|
expect(item1.avgRanking).toBe(2);
|
||||||
|
|
||||||
// Item 2 is in position 1, so avg ranking should be 1
|
// Item 2 is in position 1, so avg ranking should be 1
|
||||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
const item2 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||||
|
);
|
||||||
expect(item2.count).toBe(1);
|
expect(item2.count).toBe(1);
|
||||||
expect(item2.avgRanking).toBe(1);
|
expect(item2.avgRanking).toBe(1);
|
||||||
|
|
||||||
// Item 3 is in position 3, so avg ranking should be 3
|
// Item 3 is in position 3, so avg ranking should be 3
|
||||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
const item3 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||||
|
);
|
||||||
expect(item3.count).toBe(1);
|
expect(item3.count).toBe(1);
|
||||||
expect(item3.avgRanking).toBe(3);
|
expect(item3.avgRanking).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -830,10 +831,12 @@ describe("getQuestionSummary", () => {
|
|||||||
expect((summary[0] as any).choices).toHaveLength(3);
|
expect((summary[0] as any).choices).toHaveLength(3);
|
||||||
|
|
||||||
// All items should have count 0 and avgRanking 0
|
// All items should have count 0 and avgRanking 0
|
||||||
(summary[0] as any).choices.forEach((choice) => {
|
(summary[0] as any).choices.forEach(
|
||||||
expect(choice.count).toBe(0);
|
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||||
expect(choice.avgRanking).toBe(0);
|
expect(choice.count).toBe(0);
|
||||||
});
|
expect(choice.avgRanking).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles ranking question with non-array answers", async () => {
|
test("getQuestionSummary handles ranking question with non-array answers", async () => {
|
||||||
@@ -894,10 +897,12 @@ describe("getQuestionSummary", () => {
|
|||||||
expect((summary[0] as any).choices).toHaveLength(3);
|
expect((summary[0] as any).choices).toHaveLength(3);
|
||||||
|
|
||||||
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
|
// All items should have count 0 and avgRanking 0 since we had no valid ranking data
|
||||||
(summary[0] as any).choices.forEach((choice) => {
|
(summary[0] as any).choices.forEach(
|
||||||
expect(choice.count).toBe(0);
|
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||||
expect(choice.avgRanking).toBe(0);
|
expect(choice.count).toBe(0);
|
||||||
});
|
expect(choice.avgRanking).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles ranking question with values not in choices", async () => {
|
test("getQuestionSummary handles ranking question with values not in choices", async () => {
|
||||||
@@ -958,17 +963,23 @@ describe("getQuestionSummary", () => {
|
|||||||
expect((summary[0] as any).choices).toHaveLength(3);
|
expect((summary[0] as any).choices).toHaveLength(3);
|
||||||
|
|
||||||
// Item 1 is in position 1, so avg ranking should be 1
|
// Item 1 is in position 1, so avg ranking should be 1
|
||||||
const item1 = (summary[0] as any).choices.find((c) => c.value === "Item 1");
|
const item1 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 1"
|
||||||
|
);
|
||||||
expect(item1.count).toBe(1);
|
expect(item1.count).toBe(1);
|
||||||
expect(item1.avgRanking).toBe(1);
|
expect(item1.avgRanking).toBe(1);
|
||||||
|
|
||||||
// Item 2 was not ranked, so should have count 0 and avgRanking 0
|
// Item 2 was not ranked, so should have count 0 and avgRanking 0
|
||||||
const item2 = (summary[0] as any).choices.find((c) => c.value === "Item 2");
|
const item2 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 2"
|
||||||
|
);
|
||||||
expect(item2.count).toBe(0);
|
expect(item2.count).toBe(0);
|
||||||
expect(item2.avgRanking).toBe(0);
|
expect(item2.avgRanking).toBe(0);
|
||||||
|
|
||||||
// Item 3 is in position 3, so avg ranking should be 3
|
// Item 3 is in position 3, so avg ranking should be 3
|
||||||
const item3 = (summary[0] as any).choices.find((c) => c.value === "Item 3");
|
const item3 = (summary[0] as any).choices.find(
|
||||||
|
(c: { value: string; count: number; avgRanking: number }) => c.value === "Item 3"
|
||||||
|
);
|
||||||
expect(item3.count).toBe(1);
|
expect(item3.count).toBe(1);
|
||||||
expect(item3.avgRanking).toBe(3);
|
expect(item3.avgRanking).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -986,7 +997,11 @@ describe("getSurveySummary", () => {
|
|||||||
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
|
// Since getSurveySummary calls getResponsesForSummary internally, we'll mock prisma.response.findMany
|
||||||
// which is used by the actual implementation of getResponsesForSummary.
|
// which is used by the actual implementation of getResponsesForSummary.
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
mockResponses.map((r: Record<string, unknown>) => ({
|
||||||
|
...r,
|
||||||
|
contactId: null,
|
||||||
|
personAttributes: {},
|
||||||
|
})) as any
|
||||||
);
|
);
|
||||||
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
|
vi.mocked(getDisplayCountBySurveyId).mockResolvedValue(10);
|
||||||
|
|
||||||
@@ -1020,8 +1035,8 @@ describe("getSurveySummary", () => {
|
|||||||
test("handles filterCriteria", async () => {
|
test("handles filterCriteria", async () => {
|
||||||
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
const filterCriteria: TResponseFilterCriteria = { finished: true };
|
||||||
const finishedResponses = mockResponses
|
const finishedResponses = mockResponses
|
||||||
.filter((r) => r.finished)
|
.filter((r: Record<string, unknown>) => r.finished)
|
||||||
.map((r) => ({ ...r, contactId: null, personAttributes: {} }));
|
.map((r: Record<string, unknown>) => ({ ...r, contactId: null, personAttributes: {} }));
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
|
vi.mocked(prisma.response.findMany).mockResolvedValue(finishedResponses as any);
|
||||||
|
|
||||||
await getSurveySummary(mockSurveyId, filterCriteria);
|
await getSurveySummary(mockSurveyId, filterCriteria);
|
||||||
@@ -1043,7 +1058,11 @@ describe("getResponsesForSummary", () => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
|
vi.mocked(getSurvey).mockResolvedValue(mockBaseSurvey);
|
||||||
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
vi.mocked(prisma.response.findMany).mockResolvedValue(
|
||||||
mockResponses.map((r) => ({ ...r, contactId: null, personAttributes: {} })) as any
|
mockResponses.map((r: Record<string, unknown>) => ({
|
||||||
|
...r,
|
||||||
|
contactId: null,
|
||||||
|
personAttributes: {},
|
||||||
|
})) as any
|
||||||
);
|
);
|
||||||
// React cache is already mocked globally - no need to mock it again
|
// React cache is already mocked globally - no need to mock it again
|
||||||
});
|
});
|
||||||
@@ -1842,23 +1861,63 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(2);
|
expect(summary[0].responseCount).toBe(2);
|
||||||
|
|
||||||
// Verify Speed row
|
// Verify Speed row
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||||
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
|
expect(speedRow.columnPercentages).toHaveLength(4); // 4 columns
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
expect(
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(50);
|
||||||
|
expect(
|
||||||
|
speedRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||||
|
).percentage
|
||||||
|
).toBe(50);
|
||||||
|
|
||||||
// Verify Quality row
|
// Verify Quality row
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(2);
|
expect(qualityRow.totalResponsesForRow).toBe(2);
|
||||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(50);
|
expect(
|
||||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
qualityRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||||
|
).percentage
|
||||||
|
).toBe(50);
|
||||||
|
expect(
|
||||||
|
qualityRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||||
|
).percentage
|
||||||
|
).toBe(50);
|
||||||
|
|
||||||
// Verify Price row
|
// Verify Price row
|
||||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
const priceRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Price"
|
||||||
|
);
|
||||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||||
expect(priceRow.columnPercentages.find((col) => col.column === "Poor").percentage).toBe(50);
|
expect(
|
||||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
priceRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Poor")
|
||||||
|
.percentage
|
||||||
|
).toBe(50);
|
||||||
|
expect(
|
||||||
|
priceRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||||
|
).percentage
|
||||||
|
).toBe(50);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
|
test("getQuestionSummary correctly processes Matrix question with non-default language responses", async () => {
|
||||||
@@ -1949,19 +2008,48 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(1);
|
expect(summary[0].responseCount).toBe(1);
|
||||||
|
|
||||||
// Verify Speed row with localized values mapped to default language
|
// Verify Speed row with localized values mapped to default language
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
expect(
|
||||||
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Verify Quality row
|
// Verify Quality row
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Excellent").percentage).toBe(100);
|
expect(
|
||||||
|
qualityRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Excellent"
|
||||||
|
).percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Verify Price row
|
// Verify Price row
|
||||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
const priceRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Price"
|
||||||
|
);
|
||||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
expect(
|
||||||
|
priceRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||||
|
).percentage
|
||||||
|
).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
|
test("getQuestionSummary handles missing or invalid data for Matrix questions", async () => {
|
||||||
@@ -2055,12 +2143,18 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
|
expect(summary[0].responseCount).toBe(3); // Count is 3 because responses 2, 3, and 4 have the "matrix-q1" property
|
||||||
|
|
||||||
// All rows should have zero responses for all columns
|
// All rows should have zero responses for all columns
|
||||||
summary[0].data.forEach((row) => {
|
summary[0].data.forEach(
|
||||||
expect(row.totalResponsesForRow).toBe(0);
|
(row: {
|
||||||
row.columnPercentages.forEach((col) => {
|
rowLabel: string;
|
||||||
expect(col.percentage).toBe(0);
|
totalResponsesForRow: number;
|
||||||
});
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
});
|
}) => {
|
||||||
|
expect(row.totalResponsesForRow).toBe(0);
|
||||||
|
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||||
|
expect(col.percentage).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
|
test("getQuestionSummary handles partial and incomplete matrix responses", async () => {
|
||||||
@@ -2147,22 +2241,59 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(2);
|
expect(summary[0].responseCount).toBe(2);
|
||||||
|
|
||||||
// Verify Speed row - both responses provided data
|
// Verify Speed row - both responses provided data
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(50);
|
expect(
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(50);
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(50);
|
||||||
|
expect(
|
||||||
|
speedRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||||
|
).percentage
|
||||||
|
).toBe(50);
|
||||||
|
|
||||||
// Verify Quality row - only one response provided data
|
// Verify Quality row - only one response provided data
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(1);
|
expect(qualityRow.totalResponsesForRow).toBe(1);
|
||||||
expect(qualityRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
expect(
|
||||||
|
qualityRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Good"
|
||||||
|
).percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Verify Price row - both responses provided data
|
// Verify Price row - both responses provided data
|
||||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
const priceRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Price"
|
||||||
|
);
|
||||||
expect(priceRow.totalResponsesForRow).toBe(2);
|
expect(priceRow.totalResponsesForRow).toBe(2);
|
||||||
|
|
||||||
// ExtraRow should not appear in the summary
|
// ExtraRow should not appear in the summary
|
||||||
expect(summary[0].data.find((row) => row.rowLabel === "ExtraRow")).toBeUndefined();
|
expect(
|
||||||
|
summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "ExtraRow"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
|
test("getQuestionSummary handles zero responses for Matrix question correctly", async () => {
|
||||||
@@ -2221,12 +2352,18 @@ describe("Matrix question type tests", () => {
|
|||||||
// All rows should have proper structure but zero counts
|
// All rows should have proper structure but zero counts
|
||||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||||
|
|
||||||
summary[0].data.forEach((row) => {
|
summary[0].data.forEach(
|
||||||
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
(row: {
|
||||||
expect(row.totalResponsesForRow).toBe(0);
|
rowLabel: string;
|
||||||
expect(row.columnPercentages[0].percentage).toBe(0);
|
totalResponsesForRow: number;
|
||||||
expect(row.columnPercentages[1].percentage).toBe(0);
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
});
|
}) => {
|
||||||
|
expect(row.columnPercentages).toHaveLength(2); // 2 columns
|
||||||
|
expect(row.totalResponsesForRow).toBe(0);
|
||||||
|
expect(row.columnPercentages[0].percentage).toBe(0);
|
||||||
|
expect(row.columnPercentages[1].percentage).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
|
test("getQuestionSummary handles Matrix question with mixed valid and invalid column values", async () => {
|
||||||
@@ -2296,21 +2433,46 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(1);
|
expect(summary[0].responseCount).toBe(1);
|
||||||
|
|
||||||
// Speed row should have a valid response
|
// Speed row should have a valid response
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
expect(
|
||||||
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Quality row should have no valid responses
|
// Quality row should have no valid responses
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||||
qualityRow.columnPercentages.forEach((col) => {
|
qualityRow.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||||
expect(col.percentage).toBe(0);
|
expect(col.percentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Price row should have a valid response
|
// Price row should have a valid response
|
||||||
const priceRow = summary[0].data.find((row) => row.rowLabel === "Price");
|
const priceRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Price"
|
||||||
|
);
|
||||||
expect(priceRow.totalResponsesForRow).toBe(1);
|
expect(priceRow.totalResponsesForRow).toBe(1);
|
||||||
expect(priceRow.columnPercentages.find((col) => col.column === "Average").percentage).toBe(100);
|
expect(
|
||||||
|
priceRow.columnPercentages.find(
|
||||||
|
(col: { column: string; percentage: number }) => col.column === "Average"
|
||||||
|
).percentage
|
||||||
|
).toBe(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
|
test("getQuestionSummary handles Matrix question with invalid row labels", async () => {
|
||||||
@@ -2381,17 +2543,48 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].data).toHaveLength(2); // 2 rows
|
expect(summary[0].data).toHaveLength(2); // 2 rows
|
||||||
|
|
||||||
// Speed row should have a valid response
|
// Speed row should have a valid response
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(1);
|
expect(speedRow.totalResponsesForRow).toBe(1);
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
expect(
|
||||||
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Quality row should have no responses
|
// Quality row should have no responses
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||||
|
|
||||||
// Invalid rows should not appear in the summary
|
// Invalid rows should not appear in the summary
|
||||||
expect(summary[0].data.find((row) => row.rowLabel === "InvalidRow")).toBeUndefined();
|
expect(
|
||||||
expect(summary[0].data.find((row) => row.rowLabel === "AnotherInvalidRow")).toBeUndefined();
|
summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "InvalidRow"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "AnotherInvalidRow"
|
||||||
|
)
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
|
test("getQuestionSummary handles Matrix question with mixed language responses", async () => {
|
||||||
@@ -2493,12 +2686,27 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(2);
|
expect(summary[0].responseCount).toBe(2);
|
||||||
|
|
||||||
// Speed row should have both responses
|
// Speed row should have both responses
|
||||||
const speedRow = summary[0].data.find((row) => row.rowLabel === "Speed");
|
const speedRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Speed"
|
||||||
|
);
|
||||||
expect(speedRow.totalResponsesForRow).toBe(2);
|
expect(speedRow.totalResponsesForRow).toBe(2);
|
||||||
expect(speedRow.columnPercentages.find((col) => col.column === "Good").percentage).toBe(100);
|
expect(
|
||||||
|
speedRow.columnPercentages.find((col: { column: string; percentage: number }) => col.column === "Good")
|
||||||
|
.percentage
|
||||||
|
).toBe(100);
|
||||||
|
|
||||||
// Quality row should have no responses
|
// Quality row should have no responses
|
||||||
const qualityRow = summary[0].data.find((row) => row.rowLabel === "Quality");
|
const qualityRow = summary[0].data.find(
|
||||||
|
(row: {
|
||||||
|
rowLabel: string;
|
||||||
|
totalResponsesForRow: number;
|
||||||
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
|
}) => row.rowLabel === "Quality"
|
||||||
|
);
|
||||||
expect(qualityRow.totalResponsesForRow).toBe(0);
|
expect(qualityRow.totalResponsesForRow).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2557,12 +2765,18 @@ describe("Matrix question type tests", () => {
|
|||||||
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
|
expect(summary[0].responseCount).toBe(0); // Counts as response even with null data
|
||||||
|
|
||||||
// Both rows should have zero responses
|
// Both rows should have zero responses
|
||||||
summary[0].data.forEach((row) => {
|
summary[0].data.forEach(
|
||||||
expect(row.totalResponsesForRow).toBe(0);
|
(row: {
|
||||||
row.columnPercentages.forEach((col) => {
|
rowLabel: string;
|
||||||
expect(col.percentage).toBe(0);
|
totalResponsesForRow: number;
|
||||||
});
|
columnPercentages: { column: string; percentage: number }[];
|
||||||
});
|
}) => {
|
||||||
|
expect(row.totalResponsesForRow).toBe(0);
|
||||||
|
row.columnPercentages.forEach((col: { column: string; percentage: number }) => {
|
||||||
|
expect(col.percentage).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2994,23 +3208,33 @@ describe("Rating question type tests", () => {
|
|||||||
expect(summary[0].average).toBe(4.25);
|
expect(summary[0].average).toBe(4.25);
|
||||||
|
|
||||||
// Verify each rating option count and percentage
|
// Verify each rating option count and percentage
|
||||||
const rating5 = summary[0].choices.find((c) => c.rating === 5);
|
const rating5 = summary[0].choices.find(
|
||||||
|
(c: { rating: number; count: number; percentage: number }) => c.rating === 5
|
||||||
|
);
|
||||||
expect(rating5.count).toBe(2);
|
expect(rating5.count).toBe(2);
|
||||||
expect(rating5.percentage).toBe(50); // 2/4 * 100
|
expect(rating5.percentage).toBe(50); // 2/4 * 100
|
||||||
|
|
||||||
const rating4 = summary[0].choices.find((c) => c.rating === 4);
|
const rating4 = summary[0].choices.find(
|
||||||
|
(c: { rating: number; count: number; percentage: number }) => c.rating === 4
|
||||||
|
);
|
||||||
expect(rating4.count).toBe(1);
|
expect(rating4.count).toBe(1);
|
||||||
expect(rating4.percentage).toBe(25); // 1/4 * 100
|
expect(rating4.percentage).toBe(25); // 1/4 * 100
|
||||||
|
|
||||||
const rating3 = summary[0].choices.find((c) => c.rating === 3);
|
const rating3 = summary[0].choices.find(
|
||||||
|
(c: { rating: number; count: number; percentage: number }) => c.rating === 3
|
||||||
|
);
|
||||||
expect(rating3.count).toBe(1);
|
expect(rating3.count).toBe(1);
|
||||||
expect(rating3.percentage).toBe(25); // 1/4 * 100
|
expect(rating3.percentage).toBe(25); // 1/4 * 100
|
||||||
|
|
||||||
const rating2 = summary[0].choices.find((c) => c.rating === 2);
|
const rating2 = summary[0].choices.find(
|
||||||
|
(c: { rating: number; count: number; percentage: number }) => c.rating === 2
|
||||||
|
);
|
||||||
expect(rating2.count).toBe(0);
|
expect(rating2.count).toBe(0);
|
||||||
expect(rating2.percentage).toBe(0);
|
expect(rating2.percentage).toBe(0);
|
||||||
|
|
||||||
const rating1 = summary[0].choices.find((c) => c.rating === 1);
|
const rating1 = summary[0].choices.find(
|
||||||
|
(c: { rating: number; count: number; percentage: number }) => c.rating === 1
|
||||||
|
);
|
||||||
expect(rating1.count).toBe(0);
|
expect(rating1.count).toBe(0);
|
||||||
expect(rating1.percentage).toBe(0);
|
expect(rating1.percentage).toBe(0);
|
||||||
|
|
||||||
@@ -3154,10 +3378,12 @@ describe("Rating question type tests", () => {
|
|||||||
expect(summary[0].average).toBe(0);
|
expect(summary[0].average).toBe(0);
|
||||||
|
|
||||||
// Verify all ratings have 0 count and percentage
|
// Verify all ratings have 0 count and percentage
|
||||||
summary[0].choices.forEach((choice) => {
|
summary[0].choices.forEach(
|
||||||
expect(choice.count).toBe(0);
|
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||||
expect(choice.percentage).toBe(0);
|
expect(choice.count).toBe(0);
|
||||||
});
|
expect(choice.percentage).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Verify dismissed is 0
|
// Verify dismissed is 0
|
||||||
expect(summary[0].dismissed.count).toBe(0);
|
expect(summary[0].dismissed.count).toBe(0);
|
||||||
@@ -3232,15 +3458,21 @@ describe("PictureSelection question type tests", () => {
|
|||||||
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
|
expect(summary[0].selectionCount).toBe(3); // Total selections: img1, img2, img3
|
||||||
|
|
||||||
// Check individual choice counts
|
// Check individual choice counts
|
||||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
const img1 = summary[0].choices.find(
|
||||||
|
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||||
|
);
|
||||||
expect(img1.count).toBe(1);
|
expect(img1.count).toBe(1);
|
||||||
expect(img1.percentage).toBe(50);
|
expect(img1.percentage).toBe(50);
|
||||||
|
|
||||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
const img2 = summary[0].choices.find(
|
||||||
|
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||||
|
);
|
||||||
expect(img2.count).toBe(1);
|
expect(img2.count).toBe(1);
|
||||||
expect(img2.percentage).toBe(50);
|
expect(img2.percentage).toBe(50);
|
||||||
|
|
||||||
const img3 = summary[0].choices.find((c) => c.id === "img3");
|
const img3 = summary[0].choices.find(
|
||||||
|
(c: { id: string; count: number; percentage: number }) => c.id === "img3"
|
||||||
|
);
|
||||||
expect(img3.count).toBe(1);
|
expect(img3.count).toBe(1);
|
||||||
expect(img3.percentage).toBe(50);
|
expect(img3.percentage).toBe(50);
|
||||||
});
|
});
|
||||||
@@ -3311,10 +3543,12 @@ describe("PictureSelection question type tests", () => {
|
|||||||
expect(summary[0].selectionCount).toBe(0);
|
expect(summary[0].selectionCount).toBe(0);
|
||||||
|
|
||||||
// All choices should have zero count
|
// All choices should have zero count
|
||||||
summary[0].choices.forEach((choice) => {
|
summary[0].choices.forEach(
|
||||||
expect(choice.count).toBe(0);
|
(choice: { value?: string; count: number; avgRanking?: number; percentage?: number }) => {
|
||||||
expect(choice.percentage).toBe(0);
|
expect(choice.count).toBe(0);
|
||||||
});
|
expect(choice.percentage).toBe(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
|
test("getQuestionSummary handles PictureSelection with invalid choice ids", async () => {
|
||||||
@@ -3373,17 +3607,23 @@ describe("PictureSelection question type tests", () => {
|
|||||||
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
|
expect(summary[0].selectionCount).toBe(2); // Total selections including invalid one
|
||||||
|
|
||||||
// img1 should be counted
|
// img1 should be counted
|
||||||
const img1 = summary[0].choices.find((c) => c.id === "img1");
|
const img1 = summary[0].choices.find(
|
||||||
|
(c: { id: string; count: number; percentage: number }) => c.id === "img1"
|
||||||
|
);
|
||||||
expect(img1.count).toBe(1);
|
expect(img1.count).toBe(1);
|
||||||
expect(img1.percentage).toBe(100);
|
expect(img1.percentage).toBe(100);
|
||||||
|
|
||||||
// img2 should not be counted
|
// img2 should not be counted
|
||||||
const img2 = summary[0].choices.find((c) => c.id === "img2");
|
const img2 = summary[0].choices.find(
|
||||||
|
(c: { id: string; count: number; percentage: number }) => c.id === "img2"
|
||||||
|
);
|
||||||
expect(img2.count).toBe(0);
|
expect(img2.count).toBe(0);
|
||||||
expect(img2.percentage).toBe(0);
|
expect(img2.percentage).toBe(0);
|
||||||
|
|
||||||
// Invalid ID should not appear in choices
|
// Invalid ID should not appear in choices
|
||||||
expect(summary[0].choices.find((c) => c.id === "invalid-id")).toBeUndefined();
|
expect(
|
||||||
|
summary[0].choices.find((c: { id: string; count: number; percentage: number }) => c.id === "invalid-id")
|
||||||
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,9 @@ import {
|
|||||||
TResponseData,
|
TResponseData,
|
||||||
TResponseFilterCriteria,
|
TResponseFilterCriteria,
|
||||||
TResponseTtc,
|
TResponseTtc,
|
||||||
TResponseVariables,
|
|
||||||
ZResponseFilterCriteria,
|
ZResponseFilterCriteria,
|
||||||
} from "@formbricks/types/responses";
|
} from "@formbricks/types/responses";
|
||||||
import {
|
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
TSurveyElement,
|
|
||||||
TSurveyElementChoice,
|
|
||||||
TSurveyElementTypeEnum,
|
|
||||||
} from "@formbricks/types/surveys/elements";
|
|
||||||
import {
|
import {
|
||||||
TSurvey,
|
TSurvey,
|
||||||
TSurveyElementSummaryAddress,
|
TSurveyElementSummaryAddress,
|
||||||
@@ -41,8 +36,7 @@ import { getDisplayCountBySurveyId } from "@/lib/display/service";
|
|||||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
import { buildWhereClause } from "@/lib/response/utils";
|
import { buildWhereClause } from "@/lib/response/utils";
|
||||||
import { getSurvey } from "@/lib/survey/service";
|
import { getSurvey } from "@/lib/survey/service";
|
||||||
import { findElementLocation, getElementsFromBlocks } from "@/lib/survey/utils";
|
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||||
import { evaluateLogic, performActions } from "@/lib/surveyLogic/utils";
|
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { convertFloatTo2Decimal } from "./utils";
|
import { convertFloatTo2Decimal } from "./utils";
|
||||||
|
|
||||||
@@ -57,7 +51,32 @@ interface TSurveySummaryResponse {
|
|||||||
finished: boolean;
|
finished: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
|
||||||
|
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
|
||||||
|
block.elements.forEach((element) => {
|
||||||
|
acc[element.id] = block.id;
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBlockTimesForResponse = (
|
||||||
|
response: TSurveySummaryResponse,
|
||||||
|
survey: TSurvey
|
||||||
|
): Record<string, number> => {
|
||||||
|
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
|
||||||
|
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
|
||||||
|
const elementTtc = response.ttc?.[element.id] ?? 0;
|
||||||
|
return Math.max(maxTtc, elementTtc);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
acc[block.id] = maxElementTtc;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
export const getSurveySummaryMeta = (
|
export const getSurveySummaryMeta = (
|
||||||
|
survey: TSurvey,
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number,
|
displayCount: number,
|
||||||
quotas: TSurveySummary["quotas"]
|
quotas: TSurveySummary["quotas"]
|
||||||
@@ -66,9 +85,15 @@ export const getSurveySummaryMeta = (
|
|||||||
|
|
||||||
let ttcResponseCount = 0;
|
let ttcResponseCount = 0;
|
||||||
const ttcSum = responses.reduce((acc, response) => {
|
const ttcSum = responses.reduce((acc, response) => {
|
||||||
if (response.ttc?._total) {
|
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||||
|
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
|
||||||
|
|
||||||
|
// Fallback to _total for malformed surveys with no block mappings.
|
||||||
|
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
|
||||||
|
|
||||||
|
if (responseTtcTotal > 0) {
|
||||||
ttcResponseCount++;
|
ttcResponseCount++;
|
||||||
return acc + response.ttc._total;
|
return acc + responseTtcTotal;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -97,63 +122,13 @@ export const getSurveySummaryMeta = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const evaluateLogicAndGetNextElementId = (
|
// Determine whether a response interacted with a given element.
|
||||||
localSurvey: TSurvey,
|
// An element was "seen" if the respondent has a ttc entry for it OR provided an answer.
|
||||||
elements: TSurveyElement[],
|
// This is more reliable than replaying survey logic, which can misattribute impressions
|
||||||
data: TResponseData,
|
// when branching logic skips elements or when partial response data is insufficient
|
||||||
localVariables: TResponseVariables,
|
// to evaluate conditions correctly.
|
||||||
currentElementIndex: number,
|
const wasElementSeen = (response: TSurveySummaryResponse, elementId: string): boolean => {
|
||||||
currElementTemp: TSurveyElement,
|
return (response.ttc != null && response.ttc[elementId] > 0) || response.data[elementId] !== undefined;
|
||||||
selectedLanguage: string | null
|
|
||||||
): {
|
|
||||||
nextElementId: string | undefined;
|
|
||||||
updatedSurvey: TSurvey;
|
|
||||||
updatedVariables: TResponseVariables;
|
|
||||||
} => {
|
|
||||||
let updatedSurvey = { ...localSurvey };
|
|
||||||
let updatedVariables = { ...localVariables };
|
|
||||||
|
|
||||||
let firstJumpTarget: string | undefined;
|
|
||||||
|
|
||||||
const { block: currentBlock } = findElementLocation(localSurvey, currElementTemp.id);
|
|
||||||
|
|
||||||
if (currentBlock?.logic && currentBlock.logic.length > 0) {
|
|
||||||
for (const logic of currentBlock.logic) {
|
|
||||||
if (evaluateLogic(localSurvey, data, localVariables, logic.conditions, selectedLanguage ?? "default")) {
|
|
||||||
const { jumpTarget, requiredElementIds, calculations } = performActions(
|
|
||||||
updatedSurvey,
|
|
||||||
logic.actions,
|
|
||||||
data,
|
|
||||||
updatedVariables
|
|
||||||
);
|
|
||||||
|
|
||||||
if (requiredElementIds.length > 0) {
|
|
||||||
// Update blocks to mark elements as required
|
|
||||||
updatedSurvey.blocks = updatedSurvey.blocks.map((block) => ({
|
|
||||||
...block,
|
|
||||||
elements: block.elements.map((e) =>
|
|
||||||
requiredElementIds.includes(e.id) ? { ...e, required: true } : e
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
updatedVariables = { ...updatedVariables, ...calculations };
|
|
||||||
|
|
||||||
if (jumpTarget && !firstJumpTarget) {
|
|
||||||
firstJumpTarget = jumpTarget;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no jump target was set, check for a fallback logic
|
|
||||||
if (!firstJumpTarget && currentBlock?.logicFallback) {
|
|
||||||
firstJumpTarget = currentBlock.logicFallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the first jump target if found, otherwise go to the next element
|
|
||||||
const nextElementId = firstJumpTarget || elements[currentElementIndex + 1]?.id || undefined;
|
|
||||||
|
|
||||||
return { nextElementId, updatedSurvey, updatedVariables };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSurveySummaryDropOff = (
|
export const getSurveySummaryDropOff = (
|
||||||
@@ -173,69 +148,35 @@ export const getSurveySummaryDropOff = (
|
|||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||||
|
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
||||||
const surveyVariablesData = survey.variables?.reduce(
|
|
||||||
(acc, variable) => {
|
|
||||||
acc[variable.id] = variable.value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string | number>
|
|
||||||
);
|
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion
|
// Calculate total time-to-completion per element
|
||||||
|
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((elementId) => {
|
||||||
if (response.ttc && response.ttc[elementId]) {
|
const blockId = elementIdToBlockId[elementId];
|
||||||
totalTtc[elementId] += response.ttc[elementId];
|
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
||||||
|
if (blockTtc > 0) {
|
||||||
|
totalTtc[elementId] += blockTtc;
|
||||||
responseCounts[elementId]++;
|
responseCounts[elementId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let localSurvey = structuredClone(survey);
|
// Count impressions based on actual interaction data (ttc + response data)
|
||||||
let localResponseData: TResponseData = { ...response.data };
|
// instead of replaying survey logic which is unreliable with branching
|
||||||
let localVariables: TResponseVariables = {
|
let lastSeenIdx = -1;
|
||||||
...surveyVariablesData,
|
|
||||||
};
|
|
||||||
|
|
||||||
let currQuesIdx = 0;
|
for (let i = 0; i < elements.length; i++) {
|
||||||
|
const element = elements[i];
|
||||||
while (currQuesIdx < elements.length) {
|
if (wasElementSeen(response, element.id)) {
|
||||||
const currQues = elements[currQuesIdx];
|
impressionsArr[i]++;
|
||||||
if (!currQues) break;
|
lastSeenIdx = i;
|
||||||
|
|
||||||
// element is not answered and required
|
|
||||||
if (response.data[currQues.id] === undefined && currQues.required) {
|
|
||||||
dropOffArr[currQuesIdx]++;
|
|
||||||
impressionsArr[currQuesIdx]++;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impressionsArr[currQuesIdx]++;
|
// Attribute drop-off to the last element the respondent interacted with
|
||||||
|
if (!response.finished && lastSeenIdx >= 0) {
|
||||||
const { nextElementId, updatedSurvey, updatedVariables } = evaluateLogicAndGetNextElementId(
|
dropOffArr[lastSeenIdx]++;
|
||||||
localSurvey,
|
|
||||||
elements,
|
|
||||||
localResponseData,
|
|
||||||
localVariables,
|
|
||||||
currQuesIdx,
|
|
||||||
currQues,
|
|
||||||
response.language
|
|
||||||
);
|
|
||||||
|
|
||||||
localSurvey = updatedSurvey;
|
|
||||||
localVariables = updatedVariables;
|
|
||||||
|
|
||||||
if (nextElementId) {
|
|
||||||
const nextQuesIdx = elements.findIndex((q) => q.id === nextElementId);
|
|
||||||
if (!response.data[nextElementId] && !response.finished) {
|
|
||||||
dropOffArr[nextQuesIdx]++;
|
|
||||||
impressionsArr[nextQuesIdx]++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
currQuesIdx = nextQuesIdx;
|
|
||||||
} else {
|
|
||||||
currQuesIdx++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -244,6 +185,8 @@ export const getSurveySummaryDropOff = (
|
|||||||
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
totalTtc[elementId] = responseCounts[elementId] > 0 ? totalTtc[elementId] / responseCounts[elementId] : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// When the welcome card is disabled, the first element's impressions should equal displayCount
|
||||||
|
// because every survey display is an impression of the first element
|
||||||
if (!survey.welcomeCard.enabled) {
|
if (!survey.welcomeCard.enabled) {
|
||||||
dropOffArr[0] = displayCount - impressionsArr[0];
|
dropOffArr[0] = displayCount - impressionsArr[0];
|
||||||
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
if (impressionsArr[0] > displayCount) dropOffPercentageArr[0] = 0;
|
||||||
@@ -255,7 +198,7 @@ export const getSurveySummaryDropOff = (
|
|||||||
|
|
||||||
impressionsArr[0] = displayCount;
|
impressionsArr[0] = displayCount;
|
||||||
} else {
|
} else {
|
||||||
dropOffPercentageArr[0] = (dropOffArr[0] / impressionsArr[0]) * 100;
|
dropOffPercentageArr[0] = impressionsArr[0] > 0 ? (dropOffArr[0] / impressionsArr[0]) * 100 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 1; i < elements.length; i++) {
|
for (let i = 1; i < elements.length; i++) {
|
||||||
@@ -293,7 +236,10 @@ const checkForI18n = (
|
|||||||
) => {
|
) => {
|
||||||
const element = elements.find((element) => element.id === id);
|
const element = elements.find((element) => element.id === id);
|
||||||
|
|
||||||
if (element?.type === "multipleChoiceMulti" || element?.type === "ranking") {
|
if (
|
||||||
|
element?.type === TSurveyElementTypeEnum.MultipleChoiceMulti ||
|
||||||
|
element?.type === TSurveyElementTypeEnum.Ranking
|
||||||
|
) {
|
||||||
// Initialize an array to hold the choice values
|
// Initialize an array to hold the choice values
|
||||||
let choiceValues = [] as string[];
|
let choiceValues = [] as string[];
|
||||||
|
|
||||||
@@ -318,13 +264,9 @@ const checkForI18n = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return the localized value of the choice fo multiSelect single element
|
// Return the localized value of the choice fo multiSelect single element
|
||||||
if (element && "choices" in element) {
|
if (element?.type === TSurveyElementTypeEnum.MultipleChoiceSingle) {
|
||||||
const choice = element.choices?.find(
|
const choice = element.choices?.find((choice) => choice.label[languageCode] === responseData[id]);
|
||||||
(choice: TSurveyElementChoice) => choice.label?.[languageCode] === responseData[id]
|
return choice ? getLocalizedValue(choice.label, "default") || responseData[id] : responseData[id];
|
||||||
);
|
|
||||||
return choice && "label" in choice
|
|
||||||
? getLocalizedValue(choice.label, "default") || responseData[id]
|
|
||||||
: responseData[id];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseData[id];
|
return responseData[id];
|
||||||
@@ -832,13 +774,19 @@ export const getElementSummary = async (
|
|||||||
let totalResponseCount = 0;
|
let totalResponseCount = 0;
|
||||||
|
|
||||||
// Initialize count object
|
// Initialize count object
|
||||||
const countMap: Record<string, string> = rows.reduce((acc, row) => {
|
const countMap: Record<string, Record<string, number>> = rows.reduce(
|
||||||
acc[row] = columns.reduce((colAcc, col) => {
|
(acc: Record<string, Record<string, number>>, row) => {
|
||||||
colAcc[col] = 0;
|
acc[row] = columns.reduce(
|
||||||
return colAcc;
|
(colAcc: Record<string, number>, col) => {
|
||||||
}, {});
|
colAcc[col] = 0;
|
||||||
return acc;
|
return colAcc;
|
||||||
}, {});
|
},
|
||||||
|
{} as Record<string, number>
|
||||||
|
);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, Record<string, number>>
|
||||||
|
);
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
const selectedResponses = response.data[element.id] as Record<string, string>;
|
const selectedResponses = response.data[element.id] as Record<string, string>;
|
||||||
@@ -1061,10 +1009,8 @@ export const getSurveySummary = reactCache(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||||
const [meta, elementSummary] = await Promise.all([
|
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
||||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
||||||
getElementSummary(survey, elements, responses, dropOff),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
@@ -1095,7 +1041,7 @@ export const getResponsesForSummary = reactCache(
|
|||||||
[limit, ZOptionalNumber],
|
[limit, ZOptionalNumber],
|
||||||
[offset, ZOptionalNumber],
|
[offset, ZOptionalNumber],
|
||||||
[filterCriteria, ZResponseFilterCriteria.optional()],
|
[filterCriteria, ZResponseFilterCriteria.optional()],
|
||||||
[cursor, z.string().cuid2().optional()]
|
[cursor, z.cuid2().optional()]
|
||||||
);
|
);
|
||||||
|
|
||||||
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
const queryLimit = limit ?? RESPONSES_PER_PAGE;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
import { SurveyAnalysisNavigation } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation";
|
||||||
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
import { SummaryPage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryPage";
|
||||||
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
import { SurveyAnalysisCTA } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA";
|
||||||
@@ -32,26 +33,27 @@ const SurveyPage = async (props: { params: Promise<{ environmentId: string; surv
|
|||||||
const survey = await getSurvey(params.surveyId);
|
const survey = await getSurvey(params.surveyId);
|
||||||
|
|
||||||
if (!survey) {
|
if (!survey) {
|
||||||
throw new Error(t("common.survey_not_found"));
|
throw new ResourceNotFoundError(t("common.survey"), params.surveyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new AuthenticationError(t("common.not_authenticated"));
|
||||||
}
|
}
|
||||||
const isContactsEnabled = await getIsContactsEnabled();
|
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
||||||
|
|
||||||
|
const isContactsEnabled = await getIsContactsEnabled(organizationId);
|
||||||
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
const segments = isContactsEnabled ? await getSegments(environment.id) : [];
|
||||||
|
|
||||||
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
|
|
||||||
if (!organizationId) {
|
if (!organizationId) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), null);
|
||||||
}
|
}
|
||||||
const organizationBilling = await getOrganizationBilling(organizationId);
|
const organizationBilling = await getOrganizationBilling(organizationId);
|
||||||
if (!organizationBilling) {
|
if (!organizationBilling) {
|
||||||
throw new Error(t("common.organization_not_found"));
|
throw new ResourceNotFoundError(t("common.organization"), organizationId);
|
||||||
}
|
}
|
||||||
const isQuotasAllowed = await getIsQuotasEnabled(organizationBilling.plan);
|
const isQuotasAllowed = await getIsQuotasEnabled(organizationId);
|
||||||
|
|
||||||
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
// Fetch initial survey summary data on the server to prevent duplicate API calls during hydration
|
||||||
const initialSurveySummary = await getSurveySummary(surveyId);
|
const initialSurveySummary = await getSurveySummary(surveyId);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user