mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
208 Commits
randomize-
...
response-q
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc2c5e3df | ||
|
|
1797c2ae20 | ||
|
|
3b5da01c0a | ||
|
|
0f1bdce002 | ||
|
|
7c8f3e826f | ||
|
|
f21d63bb55 | ||
|
|
f223bb3d3f | ||
|
|
51001d07b6 | ||
|
|
a9eedd3c7a | ||
|
|
b0aa08fe4e | ||
|
|
8d45d24d55 | ||
|
|
8c1b9f81b9 | ||
|
|
71fad1c22b | ||
|
|
292266c597 | ||
|
|
54e589a6a0 | ||
|
|
fb3f425c27 | ||
|
|
1aaa30c6e9 | ||
|
|
8611410b21 | ||
|
|
40fa7a69c0 | ||
|
|
5eca30e513 | ||
|
|
4b78493782 | ||
|
|
2ce44b734f | ||
|
|
85d8f8c3ae | ||
|
|
3f16291137 | ||
|
|
a5958d5653 | ||
|
|
fdbdf8207a | ||
|
|
630e5489ec | ||
|
|
36943bb786 | ||
|
|
e1bbb0a10f | ||
|
|
27da540846 | ||
|
|
7d7f6ed04a | ||
|
|
ff01bc342d | ||
|
|
cd8b40b569 | ||
|
|
31c742f7a8 | ||
|
|
d6a7a2c21f | ||
|
|
499ecab691 | ||
|
|
df06540f1b | ||
|
|
a32b213ca5 | ||
|
|
6120f992a4 | ||
|
|
389a551a69 | ||
|
|
8ddbdc0e1e | ||
|
|
302c6a90c0 | ||
|
|
18e597d8a3 | ||
|
|
81d717ccff | ||
|
|
2e979c7323 | ||
|
|
4dfd15d6dd | ||
|
|
5b9bf3ff43 | ||
|
|
d2f7485098 | ||
|
|
f8fee1fba7 | ||
|
|
19249ca00f | ||
|
|
01e5700340 | ||
|
|
ff2f7660a6 | ||
|
|
2bc05e2b4a | ||
|
|
137c6447b7 | ||
|
|
ebc8f0c917 | ||
|
|
5a8d10b5b4 | ||
|
|
875815fb62 | ||
|
|
cdf526e130 | ||
|
|
b685032b34 | ||
|
|
a171f9cb00 | ||
|
|
c452f05ec2 | ||
|
|
93d91f80f2 | ||
|
|
7b764c8427 | ||
|
|
016289c8cb | ||
|
|
93a9575389 | ||
|
|
9e265adf14 | ||
|
|
eb08a0ed14 | ||
|
|
c533f37983 | ||
|
|
ca4f8385e4 | ||
|
|
3eb9aa74ed | ||
|
|
637b51464c | ||
|
|
fd9585a66e | ||
|
|
49ecbcb0c9 | ||
|
|
1132bdd66a | ||
|
|
c7d6ed9ea3 | ||
|
|
782528f169 | ||
|
|
104c78275f | ||
|
|
d9d88f7175 | ||
|
|
bf7e24cf11 | ||
|
|
c8aba01db3 | ||
|
|
a896c7e46e | ||
|
|
8018ec14a2 | ||
|
|
9c3208c860 | ||
|
|
e1063964cf | ||
|
|
38568738cc | ||
|
|
15b8358b14 | ||
|
|
2173cb2610 | ||
|
|
87b925d622 | ||
|
|
885b06cc26 | ||
|
|
adb6a5f41e | ||
|
|
3b815e22e3 | ||
|
|
4d4a5c0e64 | ||
|
|
0e89293974 | ||
|
|
c306911b3a | ||
|
|
4f276f0095 | ||
|
|
81fc97c7e9 | ||
|
|
785c5a59c6 | ||
|
|
25ecfaa883 | ||
|
|
38e2c019fa | ||
|
|
15878a4ac5 | ||
|
|
9802536ded | ||
|
|
2c7f92a4d7 | ||
|
|
c653841037 | ||
|
|
ec314c14ea | ||
|
|
c03e60ac0b | ||
|
|
cbf2343143 | ||
|
|
9d9b3ac543 | ||
|
|
591b35a70b | ||
|
|
f0c7b881d3 | ||
|
|
3fd5515db1 | ||
|
|
f32401afd6 | ||
|
|
1b9d91f1e8 | ||
|
|
1f039d707c | ||
|
|
6671d877ad | ||
|
|
2867c95494 | ||
|
|
aa55cec060 | ||
|
|
dfb6c4cd9e | ||
|
|
a9082f66e8 | ||
|
|
bf39b0fbfb | ||
|
|
e347f2179a | ||
|
|
d4f155b6bc | ||
|
|
da001834f5 | ||
|
|
f54352dd82 | ||
|
|
0fba0fae73 | ||
|
|
406ec88515 | ||
|
|
b97957d166 | ||
|
|
655ad6b9e0 | ||
|
|
f5ce42fc2d | ||
|
|
709cdf260d | ||
|
|
5c583028e0 | ||
|
|
c70008d1be | ||
|
|
13fa716fe8 | ||
|
|
c3af5b428f | ||
|
|
40e2f28e94 | ||
|
|
2964f2e079 | ||
|
|
e1a5291123 | ||
|
|
ef41f35209 | ||
|
|
2f64b202c1 | ||
|
|
2500c739ae | ||
|
|
63a9a6135b | ||
|
|
417005c6e9 | ||
|
|
cd1739c901 | ||
|
|
709917eb8f | ||
|
|
3ba70122d5 | ||
|
|
5ff025543e | ||
|
|
896d5bad12 | ||
|
|
e9dbaa3c28 | ||
|
|
d352d03071 | ||
|
|
ebefe775bb | ||
|
|
0852a961cc | ||
|
|
46f06f4c0e | ||
|
|
afb39e4aba | ||
|
|
2c6a90f82b | ||
|
|
e35f732e48 | ||
|
|
ec8b17dee2 | ||
|
|
947bc1a233 | ||
|
|
7050caa2f3 | ||
|
|
c4fd1a0a54 | ||
|
|
4de5f5c490 | ||
|
|
b3f336c959 | ||
|
|
010784c2b2 | ||
|
|
306f654617 | ||
|
|
60d0563487 | ||
|
|
777210ec42 | ||
|
|
8649522b5b | ||
|
|
71ebde06f4 | ||
|
|
d98eb5b46f | ||
|
|
6a2a8b74c8 | ||
|
|
43d5d3d719 | ||
|
|
5527f184b7 | ||
|
|
7dd5cf8b6e | ||
|
|
aec697f5b9 | ||
|
|
aa2588dd89 | ||
|
|
ed886e1794 | ||
|
|
452709dec7 | ||
|
|
a5cac35cfd | ||
|
|
3ee8485ef0 | ||
|
|
673f61be17 | ||
|
|
db86247510 | ||
|
|
090f6eef71 | ||
|
|
214d18616f | ||
|
|
3b126291a6 | ||
|
|
55a230e127 | ||
|
|
2a107ece7f | ||
|
|
7a3ef93a18 | ||
|
|
6255c9baad | ||
|
|
c322a963ab | ||
|
|
b1e8cb5a07 | ||
|
|
a391089efc | ||
|
|
1894bbe4f7 | ||
|
|
07dba90679 | ||
|
|
ca5ea315d6 | ||
|
|
646fe9c67f | ||
|
|
6a123a2399 | ||
|
|
39aa9f0941 | ||
|
|
625a4dcfae | ||
|
|
7971681d02 | ||
|
|
3dea241d7a | ||
|
|
e5ce6532f5 | ||
|
|
aa910ca3f0 | ||
|
|
c2d237a99a | ||
|
|
a371bdaedd | ||
|
|
dbbd77a8eb | ||
|
|
c28de7c079 | ||
|
|
05f1068e01 | ||
|
|
7103ec9877 | ||
|
|
9cd7a25343 | ||
|
|
2d028d18e5 |
35
.env.example
35
.env.example
@@ -25,6 +25,9 @@ NEXTAUTH_SECRET=
|
|||||||
# You can use: `openssl rand -hex 32` to generate a secure one
|
# You can use: `openssl rand -hex 32` to generate a secure one
|
||||||
CRON_SECRET=
|
CRON_SECRET=
|
||||||
|
|
||||||
|
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
##############
|
##############
|
||||||
# DATABASE #
|
# DATABASE #
|
||||||
##############
|
##############
|
||||||
@@ -39,6 +42,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
|||||||
# See optional configurations below if you want to disable these features.
|
# See optional configurations below if you want to disable these features.
|
||||||
|
|
||||||
MAIL_FROM=noreply@example.com
|
MAIL_FROM=noreply@example.com
|
||||||
|
MAIL_FROM_NAME=Formbricks
|
||||||
SMTP_HOST=localhost
|
SMTP_HOST=localhost
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=1025
|
||||||
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||||
@@ -76,6 +80,9 @@ S3_ENDPOINT_URL=
|
|||||||
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
# Force path style for S3 compatible storage (0 for disabled, 1 for enabled)
|
||||||
S3_FORCE_PATH_STYLE=0
|
S3_FORCE_PATH_STYLE=0
|
||||||
|
|
||||||
|
# Set this URL to add a custom domain to your survey links(default is WEBAPP_URL)
|
||||||
|
# SURVEY_URL=https://survey.example.com
|
||||||
|
|
||||||
#####################
|
#####################
|
||||||
# Disable Features #
|
# Disable Features #
|
||||||
#####################
|
#####################
|
||||||
@@ -96,6 +103,9 @@ PASSWORD_RESET_DISABLED=1
|
|||||||
# Organization Invite. Disable the ability for invited users to create an account.
|
# Organization Invite. Disable the ability for invited users to create an account.
|
||||||
# INVITE_DISABLED=1
|
# INVITE_DISABLED=1
|
||||||
|
|
||||||
|
# Docker cron jobs. Disable the supercronic cron jobs in the Docker image (useful for cluster setups).
|
||||||
|
# DOCKER_CRON_ENABLED=1
|
||||||
|
|
||||||
##########
|
##########
|
||||||
# Other #
|
# Other #
|
||||||
##########
|
##########
|
||||||
@@ -107,7 +117,7 @@ IMPRINT_URL=
|
|||||||
IMPRINT_ADDRESS=
|
IMPRINT_ADDRESS=
|
||||||
|
|
||||||
# Configure Turnstile in signup flow
|
# Configure Turnstile in signup flow
|
||||||
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
# TURNSTILE_SITE_KEY=
|
||||||
# TURNSTILE_SECRET_KEY=
|
# TURNSTILE_SECRET_KEY=
|
||||||
|
|
||||||
# Configure Github Login
|
# Configure Github Login
|
||||||
@@ -145,9 +155,8 @@ STRIPE_SECRET_KEY=
|
|||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|
||||||
# Configure Formbricks usage within Formbricks
|
# Configure Formbricks usage within Formbricks
|
||||||
NEXT_PUBLIC_FORMBRICKS_API_HOST=
|
FORMBRICKS_API_HOST=
|
||||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
|
FORMBRICKS_ENVIRONMENT_ID=
|
||||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
|
|
||||||
|
|
||||||
# Oauth credentials for Google sheet integration
|
# Oauth credentials for Google sheet integration
|
||||||
GOOGLE_SHEETS_CLIENT_ID=
|
GOOGLE_SHEETS_CLIENT_ID=
|
||||||
@@ -184,7 +193,9 @@ ENTERPRISE_LICENSE_KEY=
|
|||||||
UNSPLASH_ACCESS_KEY=
|
UNSPLASH_ACCESS_KEY=
|
||||||
|
|
||||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||||
# REDIS_URL=redis://localhost:6379
|
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
REDIS_DEFAULT_TTL=86400 # 1 day
|
||||||
|
|
||||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||||
# REDIS_HTTP_URL:
|
# REDIS_HTTP_URL:
|
||||||
@@ -195,15 +206,15 @@ UNKEY_ROOT_KEY=
|
|||||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||||
# CUSTOM_CACHE_DISABLED=1
|
# CUSTOM_CACHE_DISABLED=1
|
||||||
|
|
||||||
# Azure AI settings
|
# INTERCOM_APP_ID=
|
||||||
# AI_AZURE_RESSOURCE_NAME=
|
|
||||||
# AI_AZURE_API_KEY=
|
|
||||||
# AI_AZURE_EMBEDDINGS_DEPLOYMENT_ID=
|
|
||||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
|
||||||
|
|
||||||
# NEXT_PUBLIC_INTERCOM_APP_ID=
|
|
||||||
# INTERCOM_SECRET_KEY=
|
# INTERCOM_SECRET_KEY=
|
||||||
|
|
||||||
# Enable Prometheus metrics
|
# Enable Prometheus metrics
|
||||||
# PROMETHEUS_ENABLED=
|
# PROMETHEUS_ENABLED=
|
||||||
# PROMETHEUS_EXPORTER_PORT=
|
# PROMETHEUS_EXPORTER_PORT=
|
||||||
|
|
||||||
|
# The SENTRY_DSN is used for error tracking and performance monitoring with Sentry.
|
||||||
|
# SENTRY_DSN=
|
||||||
|
# The SENTRY_AUTH_TOKEN variable is picked up by the Sentry Build Plugin.
|
||||||
|
# It's used automatically by Sentry during the build for authentication when uploading source maps.
|
||||||
|
# SENTRY_AUTH_TOKEN=
|
||||||
|
|||||||
15
.github/actions/cache-build-web/action.yml
vendored
15
.github/actions/cache-build-web/action.yml
vendored
@@ -8,6 +8,14 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: "0"
|
default: "0"
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
turbo_token:
|
||||||
|
description: "Turborepo token"
|
||||||
|
required: false
|
||||||
|
turbo_team:
|
||||||
|
description: "Turborepo team"
|
||||||
|
required: false
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
@@ -57,14 +65,13 @@ runs:
|
|||||||
run: |
|
run: |
|
||||||
RANDOM_KEY=$(openssl rand -hex 32)
|
RANDOM_KEY=$(openssl rand -hex 32)
|
||||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
|
||||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${RANDOM_KEY}/" .env
|
|
||||||
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
echo "E2E_TESTING=${{ inputs.e2e_testing_mode }}" >> .env
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
pnpm build --filter=@formbricks/web...
|
pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
if: steps.cache-build.outputs.cache-hit != 'true'
|
if: steps.cache-build.outputs.cache-hit != 'true'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ inputs.turbo_token }}
|
||||||
|
TURBO_TEAM: ${{ inputs.turbo_team }}
|
||||||
|
|||||||
26
.github/copilot-instructions.md
vendored
Normal file
26
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Testing Instructions
|
||||||
|
|
||||||
|
When generating test files inside the "/app/web" path, follow these rules:
|
||||||
|
|
||||||
|
- Use vitest
|
||||||
|
- Ensure 100% code coverage
|
||||||
|
- Add as few comments as possible
|
||||||
|
- The test file should be located in the same folder as the original file
|
||||||
|
- Use the `test` function instead of `it`
|
||||||
|
- Follow the same test pattern used for other files in the package where the file is located
|
||||||
|
- All imports should be at the top of the file, not inside individual tests
|
||||||
|
- For mocking inside "test" blocks use "vi.mocked"
|
||||||
|
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||||
|
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||||
|
|
||||||
|
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||||
|
|
||||||
|
- Add this code inside the "describe" block and before any test:
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
|
||||||
|
- For click events, import userEvent from "@testing-library/user-event"
|
||||||
|
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||||
84
.github/dependabot.yml
vendored
Normal file
84
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm" # For pnpm monorepos, use npm ecosystem
|
||||||
|
directory: "/" # Root package.json
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
versioning-strategy: increase
|
||||||
|
|
||||||
|
# Apps directory packages
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/demo"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/demo-react-native"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/storybook"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/apps/web"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Packages directory
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/database"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/lib"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/types"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/config-eslint"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/config-prettier"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/config-typescript"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/js-core"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/surveys"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/packages/logger"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
4
.github/workflows/build-web.yml
vendored
4
.github/workflows/build-web.yml
vendored
@@ -4,7 +4,7 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build Formbricks-web
|
name: Build Formbricks-web
|
||||||
@@ -25,3 +25,5 @@ jobs:
|
|||||||
id: cache-build-web
|
id: cache-build-web
|
||||||
with:
|
with:
|
||||||
e2e_testing_mode: "0"
|
e2e_testing_mode: "0"
|
||||||
|
turbo_token: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
turbo_team: ${{ vars.TURBO_TEAM }}
|
||||||
|
|||||||
33
.github/workflows/cron-surveyStatusUpdate.yml
vendored
33
.github/workflows/cron-surveyStatusUpdate.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Cron - Survey status update
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
|
||||||
schedule:
|
|
||||||
# Runs "At 00:00." (see https://crontab.guru)
|
|
||||||
- cron: "0 0 * * *"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cron-weeklySummary:
|
|
||||||
env:
|
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@v2
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: cURL request
|
|
||||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
|
||||||
run: |
|
|
||||||
curl ${{ env.APP_URL }}/api/cron/survey-status \
|
|
||||||
-X POST \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
|
||||||
--fail
|
|
||||||
33
.github/workflows/cron-weeklySummary.yml
vendored
33
.github/workflows/cron-weeklySummary.yml
vendored
@@ -1,33 +0,0 @@
|
|||||||
name: Cron - Weekly summary
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
|
||||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
|
||||||
schedule:
|
|
||||||
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
|
|
||||||
- cron: "0 8 * * 1"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
cron-weeklySummary:
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
env:
|
|
||||||
APP_URL: ${{ secrets.APP_URL }}
|
|
||||||
CRON_SECRET: ${{ secrets.CRON_SECRET }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
- name: cURL request
|
|
||||||
if: ${{ env.APP_URL && env.CRON_SECRET }}
|
|
||||||
run: |
|
|
||||||
curl ${{ env.APP_URL }}/api/cron/weekly-summary \
|
|
||||||
-X POST \
|
|
||||||
-H 'content-type: application/json' \
|
|
||||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
|
||||||
--fail
|
|
||||||
93
.github/workflows/deploy-formbricks-cloud.yml
vendored
Normal file
93
.github/workflows/deploy-formbricks-cloud.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
name: Formbricks Cloud Deployment
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
VERSION:
|
||||||
|
description: 'The version of the Docker image to release'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
REPOSITORY:
|
||||||
|
description: 'The repository to use for the Docker image'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'ghcr.io/formbricks/formbricks'
|
||||||
|
ENVIRONMENT:
|
||||||
|
description: 'The environment to deploy to'
|
||||||
|
required: true
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- stage
|
||||||
|
- prod
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
VERSION:
|
||||||
|
description: 'The version of the Docker image to release'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
REPOSITORY:
|
||||||
|
description: 'The repository to use for the Docker image'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: 'ghcr.io/formbricks/formbricks'
|
||||||
|
ENVIRONMENT:
|
||||||
|
description: 'The environment to deploy to'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
helmfile-deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Configure AWS Credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||||
|
aws-region: "eu-central-1"
|
||||||
|
|
||||||
|
- name: Setup Cluster Access
|
||||||
|
run: |
|
||||||
|
aws eks update-kubeconfig --name formbricks-prod-eks --region eu-central-1
|
||||||
|
env:
|
||||||
|
AWS_REGION: eu-central-1
|
||||||
|
|
||||||
|
- uses: helmfile/helmfile-action@v2
|
||||||
|
name: Deploy Formbricks Cloud Prod
|
||||||
|
if: (github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch') && github.event.inputs.ENVIRONMENT == 'prod'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.VERSION }}
|
||||||
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
|
FORMBRICKS_S3_BUCKET: ${{ secrets.FORMBRICKS_S3_BUCKET }}
|
||||||
|
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.FORMBRICKS_INGRESS_CERT_ARN }}
|
||||||
|
FORMBRICKS_ROLE_ARN: ${{ secrets.FORMBRICKS_ROLE_ARN }}
|
||||||
|
with:
|
||||||
|
helm-plugins: >
|
||||||
|
https://github.com/databus23/helm-diff,
|
||||||
|
https://github.com/jkroepke/helm-secrets
|
||||||
|
helmfile-args: apply -l environment=prod
|
||||||
|
helmfile-auto-init: "false"
|
||||||
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
|
- uses: helmfile/helmfile-action@v2
|
||||||
|
name: Deploy Formbricks Cloud Stage
|
||||||
|
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ENVIRONMENT == 'stage'
|
||||||
|
env:
|
||||||
|
VERSION: ${{ inputs.VERSION }}
|
||||||
|
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||||
|
FORMBRICKS_INGRESS_CERT_ARN: ${{ secrets.STAGE_FORMBRICKS_INGRESS_CERT_ARN }}
|
||||||
|
FORMBRICKS_ROLE_ARN: ${{ secrets.STAGE_FORMBRICKS_ROLE_ARN }}
|
||||||
|
with:
|
||||||
|
helm-plugins: >
|
||||||
|
https://github.com/databus23/helm-diff,
|
||||||
|
https://github.com/jkroepke/helm-secrets
|
||||||
|
helmfile-args: apply -l environment=stage
|
||||||
|
helmfile-auto-init: "false"
|
||||||
|
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||||
|
|
||||||
167
.github/workflows/docker-build-validation.yml
vendored
Normal file
167
.github/workflows/docker-build-validation.yml
vendored
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
name: Docker Build Validation
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
merge_group:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate-docker-build:
|
||||||
|
name: Validate Docker Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
# Add PostgreSQL service container
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: test
|
||||||
|
POSTGRES_PASSWORD: test
|
||||||
|
POSTGRES_DB: formbricks
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
# Health check to ensure PostgreSQL is ready before using it
|
||||||
|
options: >-
|
||||||
|
--health-cmd pg_isready
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build Docker Image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./apps/web/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: true
|
||||||
|
tags: formbricks-test:${{ github.sha }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
secrets: |
|
||||||
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
|
- name: Verify PostgreSQL Connection
|
||||||
|
run: |
|
||||||
|
echo "Verifying PostgreSQL connection..."
|
||||||
|
# Install PostgreSQL client to test connection
|
||||||
|
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||||
|
|
||||||
|
# Test connection using psql
|
||||||
|
PGPASSWORD=test psql -h localhost -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL"
|
||||||
|
|
||||||
|
# Show network configuration
|
||||||
|
echo "Network configuration:"
|
||||||
|
ip addr show
|
||||||
|
netstat -tulpn | grep 5432 || echo "No process listening on port 5432"
|
||||||
|
|
||||||
|
- name: Test Docker Image with Health Check
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "🧪 Testing if the Docker image starts correctly..."
|
||||||
|
|
||||||
|
# Add extra docker run args to support host.docker.internal on Linux
|
||||||
|
DOCKER_RUN_ARGS="--add-host=host.docker.internal:host-gateway"
|
||||||
|
|
||||||
|
# Start the container with host.docker.internal pointing to the host
|
||||||
|
docker run --name formbricks-test \
|
||||||
|
$DOCKER_RUN_ARGS \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-e DATABASE_URL="postgresql://test:test@host.docker.internal:5432/formbricks" \
|
||||||
|
-e ENCRYPTION_KEY="${{ secrets.DUMMY_ENCRYPTION_KEY }}" \
|
||||||
|
-d formbricks-test:${{ github.sha }}
|
||||||
|
|
||||||
|
# Give it more time to start up
|
||||||
|
echo "Waiting 45 seconds for application to start..."
|
||||||
|
sleep 45
|
||||||
|
|
||||||
|
# Check if the container is running
|
||||||
|
if [ "$(docker inspect -f '{{.State.Running}}' formbricks-test)" != "true" ]; then
|
||||||
|
echo "❌ Container failed to start properly!"
|
||||||
|
docker logs formbricks-test
|
||||||
|
exit 1
|
||||||
|
else
|
||||||
|
echo "✅ Container started successfully!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try connecting to PostgreSQL from inside the container
|
||||||
|
echo "Testing PostgreSQL connection from inside container..."
|
||||||
|
docker exec formbricks-test sh -c 'apt-get update && apt-get install -y postgresql-client && PGPASSWORD=test psql -h host.docker.internal -U test -d formbricks -c "\dt" || echo "Failed to connect to PostgreSQL from container"'
|
||||||
|
|
||||||
|
# Try to access the health endpoint
|
||||||
|
echo "🏥 Testing /health endpoint..."
|
||||||
|
MAX_RETRIES=10
|
||||||
|
RETRY_COUNT=0
|
||||||
|
HEALTH_CHECK_SUCCESS=false
|
||||||
|
|
||||||
|
set +e # Disable exit on error to allow for retries
|
||||||
|
|
||||||
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
|
echo "Attempt $RETRY_COUNT of $MAX_RETRIES..."
|
||||||
|
|
||||||
|
# Show container logs before each attempt to help debugging
|
||||||
|
if [ $RETRY_COUNT -gt 1 ]; then
|
||||||
|
echo "📋 Current container logs:"
|
||||||
|
docker logs --tail 20 formbricks-test
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Get detailed curl output for debugging
|
||||||
|
HTTP_OUTPUT=$(curl -v -s -m 30 http://localhost:3000/health 2>&1)
|
||||||
|
CURL_EXIT_CODE=$?
|
||||||
|
|
||||||
|
echo "Curl exit code: $CURL_EXIT_CODE"
|
||||||
|
echo "Curl output: $HTTP_OUTPUT"
|
||||||
|
|
||||||
|
if [ $CURL_EXIT_CODE -eq 0 ]; then
|
||||||
|
STATUS_CODE=$(echo "$HTTP_OUTPUT" | grep -oP "HTTP/\d(\.\d)? \K\d+")
|
||||||
|
echo "Status code detected: $STATUS_CODE"
|
||||||
|
|
||||||
|
if [ "$STATUS_CODE" = "200" ]; then
|
||||||
|
echo "✅ Health check successful!"
|
||||||
|
HEALTH_CHECK_SUCCESS=true
|
||||||
|
break
|
||||||
|
else
|
||||||
|
echo "❌ Health check returned non-200 status code: $STATUS_CODE"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "❌ Curl command failed with exit code: $CURL_EXIT_CODE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting 15 seconds before next attempt..."
|
||||||
|
sleep 15
|
||||||
|
done
|
||||||
|
|
||||||
|
# Show full container logs for debugging
|
||||||
|
echo "📋 Full container logs:"
|
||||||
|
docker logs formbricks-test
|
||||||
|
|
||||||
|
# Clean up the container
|
||||||
|
echo "🧹 Cleaning up..."
|
||||||
|
docker rm -f formbricks-test
|
||||||
|
|
||||||
|
# Exit with failure if health check did not succeed
|
||||||
|
if [ "$HEALTH_CHECK_SUCCESS" != "true" ]; then
|
||||||
|
echo "❌ Health check failed after $MAX_RETRIES attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✨ Docker validation complete - all checks passed!"
|
||||||
4
.github/workflows/e2e.yml
vendored
4
.github/workflows/e2e.yml
vendored
@@ -16,6 +16,8 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
TELEMETRY_DISABLED: 1
|
TELEMETRY_DISABLED: 1
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
id-token: write
|
||||||
@@ -142,7 +144,7 @@ jobs:
|
|||||||
path: playwright-report/
|
path: playwright-report/
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
|
||||||
if: failure()
|
if: failure()
|
||||||
with:
|
with:
|
||||||
name: app-logs
|
name: app-logs
|
||||||
|
|||||||
34
.github/workflows/formbricks-release.yml
vendored
Normal file
34
.github/workflows/formbricks-release.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Build, release & deploy Formbricks images
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker-build:
|
||||||
|
name: Build & release stable docker image
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: ./.github/workflows/release-docker-github.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
helm-chart-release:
|
||||||
|
name: Release Helm Chart
|
||||||
|
uses: ./.github/workflows/release-helm-chart.yml
|
||||||
|
secrets: inherit
|
||||||
|
needs:
|
||||||
|
- docker-build
|
||||||
|
with:
|
||||||
|
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||||
|
|
||||||
|
deploy-formbricks-cloud:
|
||||||
|
name: Deploy Helm Chart to Formbricks Cloud
|
||||||
|
secrets: inherit
|
||||||
|
uses: ./.github/workflows/deploy-formbricks-cloud.yml
|
||||||
|
needs:
|
||||||
|
- docker-build
|
||||||
|
- helm-chart-release
|
||||||
|
with:
|
||||||
|
VERSION: ${{ needs.docker-build.outputs.VERSION }}
|
||||||
|
ENVIRONMENT: "prod"
|
||||||
67
.github/workflows/prepare-release.yml
vendored
67
.github/workflows/prepare-release.yml
vendored
@@ -1,67 +0,0 @@
|
|||||||
name: Prepare release
|
|
||||||
run-name: Prepare release ${{ inputs.next_version }}
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
next_version:
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
description: "Version name"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
prepare_release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
|
||||||
|
|
||||||
- uses: ./.github/actions/dangerous-git-checkout
|
|
||||||
|
|
||||||
- name: Configure git
|
|
||||||
run: |
|
|
||||||
git config --local user.email "github-actions@github.com"
|
|
||||||
git config --local user.name "GitHub Actions"
|
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
|
||||||
|
|
||||||
- name: Bump version
|
|
||||||
run: |
|
|
||||||
cd apps/web
|
|
||||||
pnpm version ${{ inputs.next_version }} --no-workspaces-update
|
|
||||||
|
|
||||||
- name: Commit changes and create a branch
|
|
||||||
run: |
|
|
||||||
branch_name="release-v${{ inputs.next_version }}"
|
|
||||||
git checkout -b "$branch_name"
|
|
||||||
git add .
|
|
||||||
git commit -m "chore: release v${{ inputs.next_version }}"
|
|
||||||
git push origin "$branch_name"
|
|
||||||
|
|
||||||
- name: Create pull request
|
|
||||||
run: |
|
|
||||||
gh pr create \
|
|
||||||
--base main \
|
|
||||||
--head "release-v${{ inputs.next_version }}" \
|
|
||||||
--title "chore: bump version to v${{ inputs.next_version }}" \
|
|
||||||
--body "This PR contains the changes for the v${{ inputs.next_version }} release."
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
56
.github/workflows/release-changesets.yml
vendored
56
.github/workflows/release-changesets.yml
vendored
@@ -1,56 +0,0 @@
|
|||||||
name: Release Changesets
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
#push:
|
|
||||||
# branches:
|
|
||||||
# - main
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
|
||||||
|
|
||||||
env:
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
name: Release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 15
|
|
||||||
env:
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout Repo
|
|
||||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
|
||||||
|
|
||||||
- name: Setup Node.js 18.x
|
|
||||||
uses: actions/setup-node@7c12f8017d5436eb855f1ed4399f037a36fbd9e8 # v2.5.2
|
|
||||||
with:
|
|
||||||
node-version: 18.x
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd # v2.2.4
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
|
||||||
|
|
||||||
- name: Create Release Pull Request or Publish to npm
|
|
||||||
id: changesets
|
|
||||||
uses: changesets/action@c8bada60c408975afd1a20b3db81d6eee6789308 # v1.4.9
|
|
||||||
with:
|
|
||||||
# This expects you to have a script called release which does a build for your packages and calls changeset publish
|
|
||||||
publish: pnpm release
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
||||||
@@ -15,7 +15,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}-experimental
|
IMAGE_NAME: ${{ github.repository }}-experimental
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -52,7 +51,7 @@ jobs:
|
|||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -80,8 +79,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
secrets: |
|
||||||
cache-to: type=gha,mode=max
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
|
|||||||
33
.github/workflows/release-docker-github.yml
vendored
33
.github/workflows/release-docker-github.yml
vendored
@@ -6,10 +6,11 @@ name: Docker Release to Github
|
|||||||
# documentation.
|
# documentation.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_call:
|
||||||
push:
|
outputs:
|
||||||
tags:
|
VERSION:
|
||||||
- "v*"
|
description: release version
|
||||||
|
value: ${{ jobs.build.outputs.VERSION }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Use docker.io for Docker Hub if empty
|
# Use docker.io for Docker Hub if empty
|
||||||
@@ -18,7 +19,6 @@ env:
|
|||||||
IMAGE_NAME: ${{ github.repository }}
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -33,6 +33,9 @@ jobs:
|
|||||||
# with sigstore/fulcio when running outside of PRs.
|
# with sigstore/fulcio when running outside of PRs.
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
VERSION: ${{ steps.extract_release_tag.outputs.VERSION }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||||
@@ -42,6 +45,19 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||||
|
|
||||||
|
- name: Get Release Tag
|
||||||
|
id: extract_release_tag
|
||||||
|
run: |
|
||||||
|
TAG=${{ github.ref }}
|
||||||
|
TAG=${TAG#refs/tags/v}
|
||||||
|
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||||
|
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Update package.json version
|
||||||
|
run: |
|
||||||
|
sed -i "s/\"version\": \"0.0.0\"/\"version\": \"${{ env.RELEASE_TAG }}\"/" ./apps/web/package.json
|
||||||
|
cat ./apps/web/package.json | grep version
|
||||||
|
|
||||||
- name: Set up Depot CLI
|
- name: Set up Depot CLI
|
||||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||||
|
|
||||||
@@ -55,7 +71,7 @@ jobs:
|
|||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
- name: Log into registry ${{ env.REGISTRY }}
|
- name: Log into registry ${{ env.REGISTRY }}
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -83,8 +99,9 @@ jobs:
|
|||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
cache-from: type=gha
|
secrets: |
|
||||||
cache-to: type=gha,mode=max
|
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||||
|
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||||
|
|
||||||
# Sign the resulting Docker image digest except on PRs.
|
# Sign the resulting Docker image digest except on PRs.
|
||||||
# This will only write to the public Rekor transparency log when the Docker
|
# This will only write to the public Rekor transparency log when the Docker
|
||||||
|
|||||||
54
.github/workflows/release-docker.yml
vendored
54
.github/workflows/release-docker.yml
vendored
@@ -1,54 +0,0 @@
|
|||||||
name: Release on Dockerhub
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release-image-on-dockerhub:
|
|
||||||
name: Release on Dockerhub
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
|
||||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
|
||||||
steps:
|
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
|
||||||
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
|
||||||
with:
|
|
||||||
egress-policy: audit
|
|
||||||
|
|
||||||
- name: Checkout Repo
|
|
||||||
uses: actions/checkout@ee0669bd1cc54295c223e0bb666b733df41de1c5 # v2.7.0
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2.2.0
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@885d1462b80bc1c1c7f0b00334ad271f09369c55 # v2.10.0
|
|
||||||
|
|
||||||
- name: Get Release Tag
|
|
||||||
id: extract_release_tag
|
|
||||||
run: |
|
|
||||||
TAG=${{ github.ref }}
|
|
||||||
TAG=${TAG#refs/tags/v}
|
|
||||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@0a97817b6ade9f46837855d676c4cca3a2471fc9 # v4.2.1
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./apps/web/Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
${{ secrets.DOCKER_USERNAME }}/formbricks:${{ env.RELEASE_TAG }}
|
|
||||||
${{ secrets.DOCKER_USERNAME }}/formbricks:latest
|
|
||||||
54
.github/workflows/release-helm-chart.yml
vendored
Normal file
54
.github/workflows/release-helm-chart.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
name: Publish Helm Chart
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
VERSION:
|
||||||
|
description: 'The version of the Helm chart to release'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Extract release version
|
||||||
|
run: echo "VERSION=${{ github.event.release.tag_name }}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up Helm
|
||||||
|
uses: azure/setup-helm@5119fcb9089d432beecbf79bb2c7915207344b78 # v3.5
|
||||||
|
with:
|
||||||
|
version: latest
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username ${{ github.actor }} --password-stdin
|
||||||
|
|
||||||
|
- name: Install YQ
|
||||||
|
uses: dcarbone/install-yq-action@4075b4dca348d74bd83f2bf82d30f25d7c54539b # v1.3.1
|
||||||
|
|
||||||
|
- name: Update Chart.yaml with new version
|
||||||
|
run: |
|
||||||
|
yq -i ".version = \"${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
|
yq -i ".appVersion = \"v${{ inputs.VERSION }}\"" helm-chart/Chart.yaml
|
||||||
|
|
||||||
|
- name: Package Helm chart
|
||||||
|
run: |
|
||||||
|
helm package ./helm-chart
|
||||||
|
|
||||||
|
- name: Push Helm chart to GitHub Container Registry
|
||||||
|
run: |
|
||||||
|
helm push formbricks-${{ inputs.VERSION }}.tgz oci://ghcr.io/formbricks/helm-charts
|
||||||
10
.github/workflows/sonarqube.yml
vendored
10
.github/workflows/sonarqube.yml
vendored
@@ -23,10 +23,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||||
|
|
||||||
- name: Setup Node.js 20.x
|
- name: Setup Node.js 22.x
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 22.x
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||||
@@ -46,13 +46,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Run tests with coverage
|
- name: Run tests with coverage
|
||||||
run: |
|
run: |
|
||||||
cd apps/web
|
|
||||||
pnpm test:coverage
|
pnpm test:coverage
|
||||||
cd ../../
|
|
||||||
# The Vitest coverage config is in your vite.config.mts
|
|
||||||
|
|
||||||
- name: SonarQube Scan
|
- name: SonarQube Scan
|
||||||
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
|
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
|||||||
79
.github/workflows/terraform-plan-and-apply.yml
vendored
Normal file
79
.github/workflows/terraform-plan-and-apply.yml
vendored
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
name: 'Terraform'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
# TODO: enable it back when migration is completed.
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- "infra/terraform/**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
terraform:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
|
uses: step-security/harden-runner@4d991eb9b905ef189e4c376166672c3f2f230481 # v2.11.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
|
||||||
|
- name: Configure AWS Credentials
|
||||||
|
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
|
||||||
|
with:
|
||||||
|
role-to-assume: ${{ secrets.AWS_ASSUME_ROLE_ARN }}
|
||||||
|
aws-region: "eu-central-1"
|
||||||
|
|
||||||
|
- name: Setup Terraform
|
||||||
|
uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2
|
||||||
|
|
||||||
|
- name: Terraform Format
|
||||||
|
id: fmt
|
||||||
|
run: terraform fmt -check -recursive
|
||||||
|
continue-on-error: true
|
||||||
|
working-directory: infra/terraform
|
||||||
|
|
||||||
|
- name: Terraform Init
|
||||||
|
id: init
|
||||||
|
run: terraform init
|
||||||
|
working-directory: infra/terraform
|
||||||
|
|
||||||
|
- name: Terraform Validate
|
||||||
|
id: validate
|
||||||
|
run: terraform validate
|
||||||
|
working-directory: infra/terraform
|
||||||
|
|
||||||
|
- name: Terraform Plan
|
||||||
|
id: plan
|
||||||
|
run: terraform plan -out .planfile
|
||||||
|
working-directory: infra/terraform
|
||||||
|
|
||||||
|
- name: Post PR comment
|
||||||
|
uses: borchero/terraform-plan-comment@3399d8dbae8b05185e815e02361ede2949cd99c4 # v2.4.0
|
||||||
|
if: always() && github.ref != 'refs/heads/main' && (steps.plan.outcome == 'success' || steps.plan.outcome == 'failure')
|
||||||
|
with:
|
||||||
|
token: ${{ github.token }}
|
||||||
|
planfile: .planfile
|
||||||
|
working-directory: "infra/terraform"
|
||||||
|
|
||||||
|
- name: Terraform Apply
|
||||||
|
id: apply
|
||||||
|
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||||
|
run: terraform apply .planfile
|
||||||
|
working-directory: "infra/terraform"
|
||||||
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -72,3 +72,4 @@ infra/terraform/.terraform/
|
|||||||
# IntelliJ IDEA
|
# IntelliJ IDEA
|
||||||
/.idea/
|
/.idea/
|
||||||
/*.iml
|
/*.iml
|
||||||
|
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
images=($(yq eval '.services.*.image' packages/database/docker-compose.yml))
|
images=($(yq eval '.services.*.image' docker-compose.dev.yml))
|
||||||
|
|
||||||
pull_image() {
|
pull_image() {
|
||||||
docker pull "$1"
|
docker pull "$1"
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ if [ -f branch.json ]; then
|
|||||||
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
echo "Skipping tolgee-pull: NEXT_PUBLIC_TOLGEE_API_KEY is not set"
|
||||||
else
|
else
|
||||||
pnpm run tolgee-pull
|
pnpm run tolgee-pull
|
||||||
git add packages/lib/messages
|
git add apps/web/locales
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -4,33 +4,33 @@
|
|||||||
"patterns": ["./apps/web/**/*.ts?(x)"],
|
"patterns": ["./apps/web/**/*.ts?(x)"],
|
||||||
"projectId": 10304,
|
"projectId": 10304,
|
||||||
"pull": {
|
"pull": {
|
||||||
"path": "./packages/lib/messages"
|
"path": "./apps/web/locales"
|
||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"files": [
|
"files": [
|
||||||
{
|
{
|
||||||
"language": "en-US",
|
"language": "en-US",
|
||||||
"path": "./packages/lib/messages/en-US.json"
|
"path": "./apps/web/locales/en-US.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"language": "de-DE",
|
"language": "de-DE",
|
||||||
"path": "./packages/lib/messages/de-DE.json"
|
"path": "./apps/web/locales/de-DE.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"language": "fr-FR",
|
"language": "fr-FR",
|
||||||
"path": "./packages/lib/messages/fr-FR.json"
|
"path": "./apps/web/locales/fr-FR.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"language": "pt-BR",
|
"language": "pt-BR",
|
||||||
"path": "./packages/lib/messages/pt-BR.json"
|
"path": "./apps/web/locales/pt-BR.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"language": "zh-Hant-TW",
|
"language": "zh-Hant-TW",
|
||||||
"path": "./packages/lib/messages/zh-Hant-TW.json"
|
"path": "./apps/web/locales/zh-Hant-TW.json"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"language": "pt-PT",
|
"language": "pt-PT",
|
||||||
"path": "./packages/lib/messages/pt-PT.json"
|
"path": "./apps/web/locales/pt-PT.json"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"forceMode": "OVERRIDE"
|
"forceMode": "OVERRIDE"
|
||||||
|
|||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||||
|
"sonarlint.connectedMode.project": {
|
||||||
|
"connectionId": "formbricks",
|
||||||
|
"projectKey": "formbricks_formbricks"
|
||||||
|
},
|
||||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
"typescript.tsdk": "node_modules/typescript/lib"
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.updateImportsOnFileMove.enabled": "always"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const secondaryNavigation = [
|
|||||||
|
|
||||||
export function Sidebar(): React.JSX.Element {
|
export function Sidebar(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-grow flex-col overflow-y-auto bg-cyan-700 pb-4 pt-5">
|
<div className="flex grow flex-col overflow-y-auto bg-cyan-700 pt-5 pb-4">
|
||||||
<nav
|
<nav
|
||||||
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
className="mt-5 flex flex-1 flex-col divide-y divide-cyan-800 overflow-y-auto"
|
||||||
aria-label="Sidebar">
|
aria-label="Sidebar">
|
||||||
@@ -38,10 +38,10 @@ export function Sidebar(): React.JSX.Element {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
|
item.current ? "bg-cyan-800 text-white" : "text-cyan-100 hover:bg-cyan-600 hover:text-white",
|
||||||
"group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6"
|
"group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium"
|
||||||
)}
|
)}
|
||||||
aria-current={item.current ? "page" : undefined}>
|
aria-current={item.current ? "page" : undefined}>
|
||||||
<item.icon className="mr-4 h-6 w-6 flex-shrink-0 text-cyan-200" aria-hidden="true" />
|
<item.icon className="mr-4 h-6 w-6 shrink-0 text-cyan-200" aria-hidden="true" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -52,7 +52,7 @@ export function Sidebar(): React.JSX.Element {
|
|||||||
<a
|
<a
|
||||||
key={item.name}
|
key={item.name}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="group flex items-center rounded-md px-2 py-2 text-sm font-medium leading-6 text-cyan-100 hover:bg-cyan-600 hover:text-white">
|
className="group flex items-center rounded-md px-2 py-2 text-sm leading-6 font-medium text-cyan-100 hover:bg-cyan-600 hover:text-white">
|
||||||
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
|
<item.icon className="mr-4 h-6 w-6 text-cyan-200" aria-hidden="true" />
|
||||||
{item.name}
|
{item.name}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
@tailwind base;
|
@import 'tailwindcss';
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
@plugin '@tailwindcss/forms';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/*
|
||||||
|
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||||
|
so we've added these compatibility styles to make sure everything still
|
||||||
|
looks the same as it did with Tailwind CSS v3.
|
||||||
|
|
||||||
|
If we ever want to remove these styles, we need to add an explicit border
|
||||||
|
color utility to any element that depends on these defaults.
|
||||||
|
*/
|
||||||
|
@layer base {
|
||||||
|
*,
|
||||||
|
::after,
|
||||||
|
::before,
|
||||||
|
::backdrop,
|
||||||
|
::file-selector-button {
|
||||||
|
border-color: var(--color-gray-200, currentcolor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@formbricks/demo",
|
"name": "@formbricks/demo",
|
||||||
"version": "0.1.0",
|
"version": "0.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"clean": "rimraf .turbo node_modules .next",
|
"clean": "rimraf .turbo node_modules .next",
|
||||||
@@ -12,10 +12,14 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formbricks/js": "workspace:*",
|
"@formbricks/js": "workspace:*",
|
||||||
"lucide-react": "0.468.0",
|
"@tailwindcss/forms": "0.5.9",
|
||||||
"next": "15.1.2",
|
"@tailwindcss/postcss": "4.1.3",
|
||||||
"react": "19.0.0",
|
"lucide-react": "0.486.0",
|
||||||
"react-dom": "19.0.0"
|
"next": "15.2.4",
|
||||||
|
"postcss": "8.5.3",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"tailwindcss": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ declare const window: Window;
|
|||||||
export default function AppPage(): React.JSX.Element {
|
export default function AppPage(): React.JSX.Element {
|
||||||
const [darkMode, setDarkMode] = useState(false);
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
|
||||||
|
const userAttributes = {
|
||||||
|
"Attribute 1": "one",
|
||||||
|
"Attribute 2": "two",
|
||||||
|
"Attribute 3": "three",
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (darkMode) {
|
if (darkMode) {
|
||||||
@@ -33,18 +39,9 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
addFormbricksDebugParam();
|
addFormbricksDebugParam();
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||||
const userId = "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING";
|
void formbricks.setup({
|
||||||
const userInitAttributes = {
|
|
||||||
language: "de",
|
|
||||||
"Init Attribute 1": "eight",
|
|
||||||
"Init Attribute 2": "two",
|
|
||||||
};
|
|
||||||
|
|
||||||
void formbricks.init({
|
|
||||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
appUrl: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||||
userId,
|
|
||||||
attributes: userInitAttributes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,10 +96,10 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<p className="text-slate-700 dark:text-slate-300">
|
<p className="text-slate-700 dark:text-slate-300">
|
||||||
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
Copy the environment ID of your Formbricks app to the env variable in /apps/demo/.env
|
||||||
</p>
|
</p>
|
||||||
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded" priority />
|
<Image src={fbsetup} alt="fb setup" className="mt-4 rounded-xs" priority />
|
||||||
|
|
||||||
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
<div className="mt-4 flex-col items-start text-sm text-slate-700 sm:flex sm:items-center sm:text-base dark:text-slate-300">
|
||||||
<p className="mb-1 sm:mb-0 sm:mr-2">You're connected with env:</p>
|
<p className="mb-1 sm:mr-2 sm:mb-0">You're connected with env:</p>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<strong className="w-32 truncate sm:w-auto">
|
<strong className="w-32 truncate sm:w-auto">
|
||||||
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
|
{process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID}
|
||||||
@@ -126,19 +123,19 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<div className="md:grid md:grid-cols-3">
|
<div className="md:grid md:grid-cols-3">
|
||||||
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
<div className="col-span-3 self-start rounded-lg border border-slate-300 bg-slate-100 p-6 dark:border-slate-600 dark:bg-slate-900">
|
||||||
<h3 className="text-lg font-semibold dark:text-white">
|
<h3 className="text-lg font-semibold dark:text-white">
|
||||||
Reset person / pull data from Formbricks app
|
Set a user ID / pull data from Formbricks app
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-slate-700 dark:text-slate-300">
|
<p className="text-slate-700 dark:text-slate-300">
|
||||||
On formbricks.reset() the local state will <strong>be deleted</strong> and formbricks gets{" "}
|
On formbricks.setUserId() the user state will <strong>be fetched from Formbricks</strong> and
|
||||||
<strong>reinitialized</strong>.
|
the local state gets <strong>updated with the user state</strong>.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
className="my-4 rounded-lg bg-slate-500 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void formbricks.reset();
|
void formbricks.setUserId(userId);
|
||||||
}}>
|
}}>
|
||||||
Reset
|
Set user ID
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and
|
If you made a change in Formbricks app and it does not seem to work, hit 'Reset' and
|
||||||
@@ -158,7 +155,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
This button sends a{" "}
|
This button sends a{" "}
|
||||||
<a
|
<a
|
||||||
href="https://formbricks.com/docs/actions/no-code"
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline dark:text-blue-500"
|
className="underline dark:text-blue-500"
|
||||||
target="_blank">
|
target="_blank">
|
||||||
@@ -166,7 +163,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
as long as you created it beforehand in the Formbricks App.{" "}
|
as long as you created it beforehand in the Formbricks App.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://formbricks.com/docs/actions/no-code"
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="underline dark:text-blue-500">
|
className="underline dark:text-blue-500">
|
||||||
@@ -175,6 +172,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -190,7 +188,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
This button sets the{" "}
|
This button sets the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline dark:text-blue-500">
|
className="underline dark:text-blue-500">
|
||||||
@@ -215,7 +213,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
This button sets the{" "}
|
This button sets the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://formbricks.com/docs/attributes/custom-attributes"
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline dark:text-blue-500">
|
className="underline dark:text-blue-500">
|
||||||
@@ -240,7 +238,7 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
<p className="text-xs text-slate-700 dark:text-slate-300">
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
This button sets the{" "}
|
This button sets the{" "}
|
||||||
<a
|
<a
|
||||||
href="https://formbricks.com/docs/attributes/identify-users"
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline dark:text-blue-500">
|
className="underline dark:text-blue-500">
|
||||||
@@ -250,6 +248,110 @@ export default function AppPage(): React.JSX.Element {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void formbricks.setAttributes(userAttributes);
|
||||||
|
}}
|
||||||
|
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||||
|
Set Multiple Attributes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
This button sets the{" "}
|
||||||
|
<a
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/user-identification#setting-custom-user-attributes"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline dark:text-blue-500">
|
||||||
|
user attributes
|
||||||
|
</a>{" "}
|
||||||
|
to 'one', 'two', 'three'.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void formbricks.setLanguage("de");
|
||||||
|
}}
|
||||||
|
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600">
|
||||||
|
Set Language to 'de'
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
This button sets the{" "}
|
||||||
|
<a
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/general-features/multi-language-surveys"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline dark:text-blue-500">
|
||||||
|
language
|
||||||
|
</a>{" "}
|
||||||
|
to 'de'.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
onClick={() => {
|
||||||
|
void formbricks.track("code");
|
||||||
|
}}>
|
||||||
|
Code Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
This button sends a{" "}
|
||||||
|
<a
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline dark:text-blue-500"
|
||||||
|
target="_blank">
|
||||||
|
Code Action
|
||||||
|
</a>{" "}
|
||||||
|
as long as you created it beforehand in the Formbricks App.{" "}
|
||||||
|
<a
|
||||||
|
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
className="underline dark:text-blue-500">
|
||||||
|
Here are instructions on how to do it.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mb-4 rounded-lg bg-slate-800 px-6 py-3 text-white hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
|
||||||
|
onClick={() => {
|
||||||
|
void formbricks.logout();
|
||||||
|
}}>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-700 dark:text-slate-300">
|
||||||
|
This button logs out the user and syncs the local state with Formbricks. (Only works if a
|
||||||
|
userId is set)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
"@tailwindcss/postcss": {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
content: [
|
|
||||||
"./app/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"./pages/**/*.{js,ts,jsx,tsx}",
|
|
||||||
"./components/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
darkMode: "class",
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [require("@tailwindcss/forms")],
|
|
||||||
};
|
|
||||||
@@ -11,30 +11,30 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-plugin-react-refresh": "0.4.16",
|
"eslint-plugin-react-refresh": "0.4.19",
|
||||||
"react": "19.0.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.0.0"
|
"react-dom": "19.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "3.2.2",
|
"@chromatic-com/storybook": "3.2.6",
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
"@storybook/addon-a11y": "8.4.7",
|
"@storybook/addon-a11y": "8.6.12",
|
||||||
"@storybook/addon-essentials": "8.4.7",
|
"@storybook/addon-essentials": "8.6.12",
|
||||||
"@storybook/addon-interactions": "8.4.7",
|
"@storybook/addon-interactions": "8.6.12",
|
||||||
"@storybook/addon-links": "8.4.7",
|
"@storybook/addon-links": "8.6.12",
|
||||||
"@storybook/addon-onboarding": "8.4.7",
|
"@storybook/addon-onboarding": "8.6.12",
|
||||||
"@storybook/blocks": "8.4.7",
|
"@storybook/blocks": "8.6.12",
|
||||||
"@storybook/react": "8.4.7",
|
"@storybook/react": "8.6.12",
|
||||||
"@storybook/react-vite": "8.4.7",
|
"@storybook/react-vite": "8.6.12",
|
||||||
"@storybook/test": "8.4.7",
|
"@storybook/test": "8.6.12",
|
||||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
"@typescript-eslint/eslint-plugin": "8.29.1",
|
||||||
"@typescript-eslint/parser": "8.18.0",
|
"@typescript-eslint/parser": "8.29.1",
|
||||||
"@vitejs/plugin-react": "4.3.4",
|
"@vitejs/plugin-react": "4.3.4",
|
||||||
"esbuild": "0.25.1",
|
"esbuild": "0.25.2",
|
||||||
"eslint-plugin-storybook": "0.11.1",
|
"eslint-plugin-storybook": "0.12.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "8.4.7",
|
"storybook": "8.6.12",
|
||||||
"tsup": "8.3.5",
|
"tsup": "8.4.0",
|
||||||
"vite": "6.0.9"
|
"vite": "6.2.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
extends: ["@formbricks/eslint-config/legacy-next.js"],
|
||||||
|
ignorePatterns: ["**/package.json", "**/tsconfig.json"],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ["lib/messages/**/*.json"],
|
||||||
|
plugins: ["i18n-json"],
|
||||||
|
rules: {
|
||||||
|
"i18n-json/identical-keys": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
filePath: require("path").join(__dirname, "messages", "en-US.json"),
|
||||||
|
checkExtraKeys: false,
|
||||||
|
checkMissingKeys: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
2
apps/web/.gitignore
vendored
2
apps/web/.gitignore
vendored
@@ -50,4 +50,4 @@ uploads/
|
|||||||
.sentryclirc
|
.sentryclirc
|
||||||
|
|
||||||
# SAML Preloaded Connections
|
# SAML Preloaded Connections
|
||||||
saml-connection/
|
saml-connection/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
|
FROM node:22-alpine3.21 AS base
|
||||||
|
|
||||||
#
|
#
|
||||||
## step 1: Prune monorepo
|
## step 1: Prune monorepo
|
||||||
@@ -22,19 +22,27 @@ RUN npm install -g corepack@latest
|
|||||||
RUN corepack enable
|
RUN corepack enable
|
||||||
|
|
||||||
# Install necessary build tools and compilers
|
# Install necessary build tools and compilers
|
||||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev jq
|
RUN apk update && apk add --no-cache cmake g++ gcc jq make openssl-dev python3
|
||||||
|
|
||||||
# Set hardcoded environment variables
|
# BuildKit secret handling without hardcoded fallback values
|
||||||
ENV DATABASE_URL="postgresql://placeholder:for@build:5432/gets_overwritten_at_runtime?schema=public"
|
# This approach relies entirely on secrets passed from GitHub Actions
|
||||||
ENV NEXTAUTH_SECRET="placeholder_for_next_auth_of_64_chars_get_overwritten_at_runtime"
|
RUN echo '#!/bin/sh' > /tmp/read-secrets.sh && \
|
||||||
ENV ENCRYPTION_KEY="placeholder_for_build_key_of_64_chars_get_overwritten_at_runtime"
|
echo 'if [ -f "/run/secrets/database_url" ]; then' >> /tmp/read-secrets.sh && \
|
||||||
ENV CRON_SECRET="placeholder_for_cron_secret_of_64_chars_get_overwritten_at_runtime"
|
echo ' export DATABASE_URL=$(cat /run/secrets/database_url)' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'else' >> /tmp/read-secrets.sh && \
|
||||||
|
echo ' echo "DATABASE_URL secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'if [ -f "/run/secrets/encryption_key" ]; then' >> /tmp/read-secrets.sh && \
|
||||||
|
echo ' export ENCRYPTION_KEY=$(cat /run/secrets/encryption_key)' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'else' >> /tmp/read-secrets.sh && \
|
||||||
|
echo ' echo "ENCRYPTION_KEY secret not found. Build may fail if this is required."' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'fi' >> /tmp/read-secrets.sh && \
|
||||||
|
echo 'exec "$@"' >> /tmp/read-secrets.sh && \
|
||||||
|
chmod +x /tmp/read-secrets.sh
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_SENTRY_DSN
|
# Increase Node.js memory limit as a regular build argument
|
||||||
ARG SENTRY_AUTH_TOKEN
|
ARG NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
|
ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||||
# Increase Node.js memory limit
|
|
||||||
# ENV NODE_OPTIONS="--max_old_space_size=4096"
|
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -53,8 +61,11 @@ RUN touch apps/web/.env
|
|||||||
# Install the dependencies
|
# Install the dependencies
|
||||||
RUN pnpm install
|
RUN pnpm install
|
||||||
|
|
||||||
# Build the project
|
# Build the project using our secret reader script
|
||||||
RUN NODE_OPTIONS="--max_old_space_size=4096" pnpm build --filter=@formbricks/web...
|
# This mounts the secrets only during this build step without storing them in layers
|
||||||
|
RUN --mount=type=secret,id=database_url \
|
||||||
|
--mount=type=secret,id=encryption_key \
|
||||||
|
/tmp/read-secrets.sh pnpm build --filter=@formbricks/web...
|
||||||
|
|
||||||
# Extract Prisma version
|
# Extract Prisma version
|
||||||
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_version.txt
|
||||||
@@ -70,37 +81,71 @@ RUN corepack enable
|
|||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
&& apk add --no-cache supercronic \
|
&& apk add --no-cache supercronic \
|
||||||
# && addgroup --system --gid 1001 nodejs \
|
# && addgroup --system --gid 1001 nodejs \
|
||||||
&& adduser --system --uid 1001 nextjs
|
&& addgroup -S nextjs \
|
||||||
|
&& adduser -S -u 1001 -G nextjs nextjs
|
||||||
|
|
||||||
WORKDIR /home/nextjs
|
WORKDIR /home/nextjs
|
||||||
|
|
||||||
|
# Ensure no write permissions are assigned to the copied resources
|
||||||
|
COPY --from=installer /app/apps/web/.next/standalone ./
|
||||||
|
RUN chown -R nextjs:nextjs ./ && chmod -R 755 ./
|
||||||
|
|
||||||
COPY --from=installer /app/apps/web/next.config.mjs .
|
COPY --from=installer /app/apps/web/next.config.mjs .
|
||||||
|
RUN chmod 644 ./next.config.mjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/web/package.json .
|
COPY --from=installer /app/apps/web/package.json .
|
||||||
# Leverage output traces to reduce image size
|
RUN chmod 644 ./package.json
|
||||||
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/standalone ./
|
COPY --from=installer /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/.next/static ./apps/web/.next/static
|
RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/apps/web/public ./apps/web/public
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/package.json ./packages/database/package.json
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/migration ./packages/database/migration
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/packages/database/src ./packages/database/src
|
|
||||||
|
|
||||||
# Copy Prisma-specific generated files
|
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||||
COPY --from=installer --chown=nextjs:nextjs /app/node_modules/.prisma ./node_modules/.prisma
|
|
||||||
|
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||||
|
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||||
|
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/database/migration ./packages/database/migration
|
||||||
|
RUN chown -R nextjs:nextjs ./packages/database/migration && chmod -R 755 ./packages/database/migration
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/database/src ./packages/database/src
|
||||||
|
RUN chown -R nextjs:nextjs ./packages/database/src && chmod -R 755 ./packages/database/src
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/database/node_modules ./packages/database/node_modules
|
||||||
|
RUN chown -R nextjs:nextjs ./packages/database/node_modules && chmod -R 755 ./packages/database/node_modules
|
||||||
|
|
||||||
|
COPY --from=installer /app/packages/logger/dist ./packages/database/node_modules/@formbricks/logger/dist
|
||||||
|
RUN chown -R nextjs:nextjs ./packages/database/node_modules/@formbricks/logger/dist && chmod -R 755 ./packages/database/node_modules/@formbricks/logger/dist
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/@prisma/client ./node_modules/@prisma/client
|
||||||
|
RUN chown -R nextjs:nextjs ./node_modules/@prisma/client && chmod -R 755 ./node_modules/@prisma/client
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
RUN chown -R nextjs:nextjs ./node_modules/.prisma && chmod -R 755 ./node_modules/.prisma
|
||||||
|
|
||||||
|
COPY --from=installer /prisma_version.txt .
|
||||||
|
RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
|
||||||
|
|
||||||
COPY --from=installer --chown=nextjs:nextjs /prisma_version.txt .
|
|
||||||
COPY /docker/cronjobs /app/docker/cronjobs
|
COPY /docker/cronjobs /app/docker/cronjobs
|
||||||
|
RUN chmod -R 755 /app/docker/cronjobs
|
||||||
|
|
||||||
# Copy only @paralleldrive/cuid2 and @noble/hashes
|
|
||||||
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
|
||||||
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
|
||||||
|
|
||||||
RUN npm install -g tsx typescript prisma
|
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
|
||||||
|
RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||||
|
|
||||||
|
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||||
|
RUN chmod -R 755 ./node_modules/zod
|
||||||
|
|
||||||
|
RUN npm install -g tsx typescript prisma pino-pretty
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV HOSTNAME "0.0.0.0"
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
ENV NODE_ENV="production"
|
||||||
# USER nextjs
|
# USER nextjs
|
||||||
|
|
||||||
# Prepare volume for uploads
|
# Prepare volume for uploads
|
||||||
@@ -111,7 +156,12 @@ VOLUME /home/nextjs/apps/web/uploads/
|
|||||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD supercronic -quiet /app/docker/cronjobs & \
|
CMD if [ "${DOCKER_CRON_ENABLED:-1}" = "1" ]; then \
|
||||||
|
echo "Starting cron jobs..."; \
|
||||||
|
supercronic -quiet /app/docker/cronjobs & \
|
||||||
|
else \
|
||||||
|
echo "Docker cron jobs are disabled via DOCKER_CRON_ENABLED=0"; \
|
||||||
|
fi; \
|
||||||
(cd packages/database && npm run db:migrate:deploy) && \
|
(cd packages/database && npm run db:migrate:deploy) && \
|
||||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||||
exec node apps/web/server.js
|
exec node apps/web/server.js
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TProjectConfigChannel } from "@formbricks/types/project";
|
import { TProjectConfigChannel } from "@formbricks/types/project";
|
||||||
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { afterEach, beforeAll, describe, expect, test, vi } from "vitest";
|
||||||
|
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
|
||||||
|
|
||||||
|
// Mock react-hot-toast so we can assert that a success message is shown
|
||||||
|
vi.mock("react-hot-toast", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set up a spy for navigator.clipboard.writeText so it becomes a ViTest spy.
|
||||||
|
beforeAll(() => {
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: {
|
||||||
|
// Using a mockResolvedValue resolves the promise as writeText is async.
|
||||||
|
writeText: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OnboardingSetupInstructions", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide some default props for testing
|
||||||
|
const defaultProps = {
|
||||||
|
environmentId: "env-123",
|
||||||
|
webAppUrl: "https://example.com",
|
||||||
|
channel: "app" as const, // Assuming channel is either "app" or "website"
|
||||||
|
widgetSetupCompleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
test("renders HTML tab content by default", () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
|
||||||
|
// Since the default active tab is "html", we check for a unique text
|
||||||
|
expect(
|
||||||
|
screen.getByText(/environments.connect.insert_this_code_into_the_head_tag_of_your_website/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The HTML snippet contains a marker comment
|
||||||
|
expect(screen.getByText("START")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify the "Copy Code" button is present
|
||||||
|
expect(screen.getByRole("button", { name: /common.copy_code/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders NPM tab content when selected", async () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
// Click on the "NPM" tab to switch views.
|
||||||
|
const npmTab = screen.getByText("NPM");
|
||||||
|
await user.click(npmTab);
|
||||||
|
|
||||||
|
// Check that the install commands are present
|
||||||
|
expect(screen.getByText(/npm install @formbricks\/js/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/yarn add @formbricks\/js/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Verify the "Read Docs" link has the correct URL (based on channel prop)
|
||||||
|
const readDocsLink = screen.getByRole("link", { name: /common.read_docs/i });
|
||||||
|
expect(readDocsLink).toHaveAttribute("href", "https://formbricks.com/docs/app-surveys/framework-guides");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("copies HTML snippet to clipboard and shows success toast when Copy Code button is clicked", async () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
const writeTextSpy = vi.spyOn(navigator.clipboard, "writeText");
|
||||||
|
|
||||||
|
// Click the "Copy Code" button
|
||||||
|
const copyButton = screen.getByRole("button", { name: /common.copy_code/i });
|
||||||
|
await user.click(copyButton);
|
||||||
|
|
||||||
|
// Ensure navigator.clipboard.writeText was called.
|
||||||
|
expect(writeTextSpy).toHaveBeenCalled();
|
||||||
|
const writtenText = (navigator.clipboard.writeText as any).mock.calls[0][0] as string;
|
||||||
|
|
||||||
|
// Check that the pasted snippet contains the expected environment values
|
||||||
|
expect(writtenText).toContain('var appUrl = "https://example.com"');
|
||||||
|
expect(writtenText).toContain('var environmentId = "env-123"');
|
||||||
|
|
||||||
|
// Verify that a success toast was shown
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders step-by-step manual link with correct URL in HTML tab", () => {
|
||||||
|
render(<OnboardingSetupInstructions {...defaultProps} />);
|
||||||
|
const manualLink = screen.getByRole("link", { name: /common.step_by_step_manual/i });
|
||||||
|
expect(manualLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"https://formbricks.com/docs/app-surveys/framework-guides#html"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,10 +34,9 @@ export const OnboardingSetupInstructions = ({
|
|||||||
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
const htmlSnippetForAppSurveys = `<!-- START Formbricks Surveys -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var apiHost = "${webAppUrl}";
|
var appUrl = "${webAppUrl}";
|
||||||
var environmentId = "${environmentId}";
|
var environmentId = "${environmentId}";
|
||||||
var userId = "testUser";
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost, userId: userId})},500)}();
|
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
@@ -45,9 +44,9 @@ export const OnboardingSetupInstructions = ({
|
|||||||
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
const htmlSnippetForWebsiteSurveys = `<!-- START Formbricks Surveys -->
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
!function(){
|
!function(){
|
||||||
var apiHost = "${webAppUrl}";
|
var appUrl = "${webAppUrl}";
|
||||||
var environmentId = "${environmentId}";
|
var environmentId = "${environmentId}";
|
||||||
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=apiHost+"/js/formbricks.umd.cjs";var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e),setTimeout(function(){window.formbricks.init({environmentId: environmentId, apiHost: apiHost})},500)}();
|
var t=document.createElement("script");t.type="text/javascript",t.async=!0,t.src=appUrl+"/js/formbricks.umd.cjs",t.onload=function(){window.formbricks?window.formbricks.setup({environmentId:environmentId,appUrl:appUrl}):console.error("Formbricks library failed to load properly. The formbricks object is not available.");};var e=document.getElementsByTagName("script")[0];e.parentNode.insertBefore(t,e)}();
|
||||||
</script>
|
</script>
|
||||||
<!-- END Formbricks Surveys -->
|
<!-- END Formbricks Surveys -->
|
||||||
`;
|
`;
|
||||||
@@ -56,10 +55,9 @@ export const OnboardingSetupInstructions = ({
|
|||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.init({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
environmentId: "${environmentId}",
|
||||||
apiHost: "${webAppUrl}",
|
appUrl: "${webAppUrl}",
|
||||||
userId: "testUser",
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,9 +73,9 @@ export const OnboardingSetupInstructions = ({
|
|||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
formbricks.init({
|
formbricks.setup({
|
||||||
environmentId: "${environmentId}",
|
environmentId: "${environmentId}",
|
||||||
apiHost: "${webAppUrl}",
|
appUrl: "${webAppUrl}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
|
||||||
|
import { WEBAPP_URL } from "@/lib/constants";
|
||||||
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
|
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
|
||||||
|
|
||||||
interface ConnectPageProps {
|
interface ConnectPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -44,7 +44,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-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}`}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { replaceQuestionPresetPlaceholders } from "@formbricks/lib/utils/templates";
|
import { replaceQuestionPresetPlaceholders } from "@/lib/utils/templates";
|
||||||
import { TProject } from "@formbricks/types/project";
|
import { TProject } from "@formbricks/types/project";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { getDefaultEndingCard } from "@/app/lib/templates";
|
import {
|
||||||
|
buildCTAQuestion,
|
||||||
|
buildNPSQuestion,
|
||||||
|
buildOpenTextQuestion,
|
||||||
|
buildRatingQuestion,
|
||||||
|
getDefaultEndingCard,
|
||||||
|
} from "@/app/lib/survey-builder";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { TFnType } from "@tolgee/react";
|
import { TFnType } from "@tolgee/react";
|
||||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
import { logger } from "@formbricks/logger";
|
||||||
import { TXMTemplate } from "@formbricks/types/templates";
|
import { TXMTemplate } from "@formbricks/types/templates";
|
||||||
|
|
||||||
function logError(error: Error, context: string) {
|
|
||||||
console.error(`Error in ${context}:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
@@ -19,7 +21,7 @@ export const getXMSurveyDefault = (t: TFnType): TXMTemplate => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "getXMSurveyDefault");
|
logger.error(error, "Failed to create default XM survey template");
|
||||||
throw error; // Re-throw after logging
|
throw error; // Re-throw after logging
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -29,35 +31,26 @@ const npsSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.nps_survey_name"),
|
name: t("templates.nps_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildNPSQuestion({
|
||||||
id: createId(),
|
headline: t("templates.nps_survey_question_1_headline"),
|
||||||
type: TSurveyQuestionTypeEnum.NPS,
|
|
||||||
headline: { default: t("templates.nps_survey_question_1_headline") },
|
|
||||||
required: true,
|
required: true,
|
||||||
lowerLabel: { default: t("templates.nps_survey_question_1_lower_label") },
|
lowerLabel: t("templates.nps_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.nps_survey_question_1_upper_label") },
|
upperLabel: t("templates.nps_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: true,
|
isColorCodingEnabled: true,
|
||||||
},
|
t,
|
||||||
{
|
}),
|
||||||
id: createId(),
|
buildOpenTextQuestion({
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
headline: t("templates.nps_survey_question_2_headline"),
|
||||||
headline: { default: t("templates.nps_survey_question_2_headline") },
|
|
||||||
required: false,
|
required: false,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
buildOpenTextQuestion({
|
||||||
},
|
headline: t("templates.nps_survey_question_3_headline"),
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
|
||||||
headline: { default: t("templates.nps_survey_question_3_headline") },
|
|
||||||
required: false,
|
required: false,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -70,9 +63,8 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.star_rating_survey_name"),
|
name: t("templates.star_rating_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildRatingQuestion({
|
||||||
id: reusableQuestionIds[0],
|
id: reusableQuestionIds[0],
|
||||||
type: TSurveyQuestionTypeEnum.Rating,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -105,16 +97,15 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
range: 5,
|
range: 5,
|
||||||
scale: "number",
|
scale: "number",
|
||||||
headline: { default: t("templates.star_rating_survey_question_1_headline") },
|
headline: t("templates.star_rating_survey_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
lowerLabel: { default: t("templates.star_rating_survey_question_1_lower_label") },
|
lowerLabel: t("templates.star_rating_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.star_rating_survey_question_1_upper_label") },
|
upperLabel: t("templates.star_rating_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: false,
|
t,
|
||||||
},
|
}),
|
||||||
{
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
html: { default: t("templates.star_rating_survey_question_2_html") },
|
html: t("templates.star_rating_survey_question_2_html"),
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -141,25 +132,23 @@ const starRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
headline: { default: t("templates.star_rating_survey_question_2_headline") },
|
headline: t("templates.star_rating_survey_question_2_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonLabel: { default: t("templates.star_rating_survey_question_2_button_label") },
|
buttonLabel: t("templates.star_rating_survey_question_2_button_label"),
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
},
|
t,
|
||||||
{
|
}),
|
||||||
|
buildOpenTextQuestion({
|
||||||
id: reusableQuestionIds[2],
|
id: reusableQuestionIds[2],
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
headline: t("templates.star_rating_survey_question_3_headline"),
|
||||||
headline: { default: t("templates.star_rating_survey_question_3_headline") },
|
|
||||||
required: true,
|
required: true,
|
||||||
subheader: { default: t("templates.star_rating_survey_question_3_subheader") },
|
subheader: t("templates.star_rating_survey_question_3_subheader"),
|
||||||
buttonLabel: { default: t("templates.star_rating_survey_question_3_button_label") },
|
buttonLabel: t("templates.star_rating_survey_question_3_button_label"),
|
||||||
placeholder: { default: t("templates.star_rating_survey_question_3_placeholder") },
|
placeholder: t("templates.star_rating_survey_question_3_placeholder"),
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -172,9 +161,8 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.csat_survey_name"),
|
name: t("templates.csat_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildRatingQuestion({
|
||||||
id: reusableQuestionIds[0],
|
id: reusableQuestionIds[0],
|
||||||
type: TSurveyQuestionTypeEnum.Rating,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -207,15 +195,14 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
range: 5,
|
range: 5,
|
||||||
scale: "smiley",
|
scale: "smiley",
|
||||||
headline: { default: t("templates.csat_survey_question_1_headline") },
|
headline: t("templates.csat_survey_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
lowerLabel: { default: t("templates.csat_survey_question_1_lower_label") },
|
lowerLabel: t("templates.csat_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.csat_survey_question_1_upper_label") },
|
upperLabel: t("templates.csat_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: false,
|
t,
|
||||||
},
|
}),
|
||||||
{
|
buildOpenTextQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -242,25 +229,20 @@ const csatSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
headline: { default: t("templates.csat_survey_question_2_headline") },
|
headline: t("templates.csat_survey_question_2_headline"),
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: t("templates.csat_survey_question_2_placeholder") },
|
placeholder: t("templates.csat_survey_question_2_placeholder"),
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
buildOpenTextQuestion({
|
||||||
},
|
|
||||||
{
|
|
||||||
id: reusableQuestionIds[2],
|
id: reusableQuestionIds[2],
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
headline: t("templates.csat_survey_question_3_headline"),
|
||||||
headline: { default: t("templates.csat_survey_question_3_headline") },
|
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: { default: t("templates.csat_survey_question_3_placeholder") },
|
placeholder: t("templates.csat_survey_question_3_placeholder"),
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -270,28 +252,22 @@ const cessSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.cess_survey_name"),
|
name: t("templates.cess_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildRatingQuestion({
|
||||||
id: createId(),
|
|
||||||
type: TSurveyQuestionTypeEnum.Rating,
|
|
||||||
range: 5,
|
range: 5,
|
||||||
scale: "number",
|
scale: "number",
|
||||||
headline: { default: t("templates.cess_survey_question_1_headline") },
|
headline: t("templates.cess_survey_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
lowerLabel: { default: t("templates.cess_survey_question_1_lower_label") },
|
lowerLabel: t("templates.cess_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.cess_survey_question_1_upper_label") },
|
upperLabel: t("templates.cess_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: false,
|
t,
|
||||||
},
|
}),
|
||||||
{
|
buildOpenTextQuestion({
|
||||||
id: createId(),
|
headline: t("templates.cess_survey_question_2_headline"),
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
|
||||||
headline: { default: t("templates.cess_survey_question_2_headline") },
|
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: { default: t("templates.cess_survey_question_2_placeholder") },
|
placeholder: t("templates.cess_survey_question_2_placeholder"),
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -304,9 +280,8 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...defaultSurvey,
|
...defaultSurvey,
|
||||||
name: t("templates.smileys_survey_name"),
|
name: t("templates.smileys_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildRatingQuestion({
|
||||||
id: reusableQuestionIds[0],
|
id: reusableQuestionIds[0],
|
||||||
type: TSurveyQuestionTypeEnum.Rating,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -339,16 +314,15 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
range: 5,
|
range: 5,
|
||||||
scale: "smiley",
|
scale: "smiley",
|
||||||
headline: { default: t("templates.smileys_survey_question_1_headline") },
|
headline: t("templates.smileys_survey_question_1_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
lowerLabel: { default: t("templates.smileys_survey_question_1_lower_label") },
|
lowerLabel: t("templates.smileys_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.smileys_survey_question_1_upper_label") },
|
upperLabel: t("templates.smileys_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: false,
|
t,
|
||||||
},
|
}),
|
||||||
{
|
buildCTAQuestion({
|
||||||
id: reusableQuestionIds[1],
|
id: reusableQuestionIds[1],
|
||||||
html: { default: t("templates.smileys_survey_question_2_html") },
|
html: t("templates.smileys_survey_question_2_html"),
|
||||||
type: TSurveyQuestionTypeEnum.CTA,
|
|
||||||
logic: [
|
logic: [
|
||||||
{
|
{
|
||||||
id: createId(),
|
id: createId(),
|
||||||
@@ -375,25 +349,23 @@ const smileysRatingSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
headline: { default: t("templates.smileys_survey_question_2_headline") },
|
headline: t("templates.smileys_survey_question_2_headline"),
|
||||||
required: true,
|
required: true,
|
||||||
buttonUrl: "https://formbricks.com/github",
|
buttonUrl: "https://formbricks.com/github",
|
||||||
buttonLabel: { default: t("templates.smileys_survey_question_2_button_label") },
|
buttonLabel: t("templates.smileys_survey_question_2_button_label"),
|
||||||
buttonExternal: true,
|
buttonExternal: true,
|
||||||
},
|
t,
|
||||||
{
|
}),
|
||||||
|
buildOpenTextQuestion({
|
||||||
id: reusableQuestionIds[2],
|
id: reusableQuestionIds[2],
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
headline: t("templates.smileys_survey_question_3_headline"),
|
||||||
headline: { default: t("templates.smileys_survey_question_3_headline") },
|
|
||||||
required: true,
|
required: true,
|
||||||
subheader: { default: t("templates.smileys_survey_question_3_subheader") },
|
subheader: t("templates.smileys_survey_question_3_subheader"),
|
||||||
buttonLabel: { default: t("templates.smileys_survey_question_3_button_label") },
|
buttonLabel: t("templates.smileys_survey_question_3_button_label"),
|
||||||
placeholder: { default: t("templates.smileys_survey_question_3_placeholder") },
|
placeholder: t("templates.smileys_survey_question_3_placeholder"),
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -403,37 +375,26 @@ const enpsSurvey = (t: TFnType): TXMTemplate => {
|
|||||||
...getXMSurveyDefault(t),
|
...getXMSurveyDefault(t),
|
||||||
name: t("templates.enps_survey_name"),
|
name: t("templates.enps_survey_name"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
buildNPSQuestion({
|
||||||
id: createId(),
|
headline: t("templates.enps_survey_question_1_headline"),
|
||||||
type: TSurveyQuestionTypeEnum.NPS,
|
|
||||||
headline: {
|
|
||||||
default: t("templates.enps_survey_question_1_headline"),
|
|
||||||
},
|
|
||||||
required: false,
|
required: false,
|
||||||
lowerLabel: { default: t("templates.enps_survey_question_1_lower_label") },
|
lowerLabel: t("templates.enps_survey_question_1_lower_label"),
|
||||||
upperLabel: { default: t("templates.enps_survey_question_1_upper_label") },
|
upperLabel: t("templates.enps_survey_question_1_upper_label"),
|
||||||
isColorCodingEnabled: true,
|
isColorCodingEnabled: true,
|
||||||
},
|
t,
|
||||||
{
|
}),
|
||||||
id: createId(),
|
buildOpenTextQuestion({
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
headline: t("templates.enps_survey_question_2_headline"),
|
||||||
headline: { default: t("templates.enps_survey_question_2_headline") },
|
|
||||||
required: false,
|
required: false,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
buildOpenTextQuestion({
|
||||||
},
|
headline: t("templates.enps_survey_question_3_headline"),
|
||||||
{
|
|
||||||
id: createId(),
|
|
||||||
type: TSurveyQuestionTypeEnum.OpenText,
|
|
||||||
headline: { default: t("templates.enps_survey_question_3_headline") },
|
|
||||||
required: false,
|
required: false,
|
||||||
inputType: "text",
|
inputType: "text",
|
||||||
charLimit: {
|
t,
|
||||||
enabled: false,
|
}),
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -449,7 +410,7 @@ export const getXMTemplates = (t: TFnType): TXMTemplate[] => {
|
|||||||
enpsSurvey(t),
|
enpsSurvey(t),
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logError(error, "getXMTemplates");
|
logger.error(error, "Unable to load XM templates, returning empty array");
|
||||||
return []; // Return an empty array or handle as needed
|
return []; // Return an empty array or handle as needed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
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 { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -7,9 +10,6 @@ import { getTranslate } from "@/tolgee/server";
|
|||||||
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 { getEnvironment } from "@formbricks/lib/environment/service";
|
|
||||||
import { getProjectByEnvironmentId, getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
|
||||||
|
|
||||||
interface XMTemplatePageProps {
|
interface XMTemplatePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -49,7 +49,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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-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`}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
|
import { TOrganizationTeam } from "@/app/(app)/(onboarding)/types/onboarding";
|
||||||
|
import { cache } from "@/lib/cache";
|
||||||
import { teamCache } from "@/lib/cache/team";
|
import { teamCache } from "@/lib/cache/team";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { cache } from "@formbricks/lib/cache";
|
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
import {
|
import {
|
||||||
@@ -24,8 +26,6 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
|
||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ export const LandingSidebar = ({
|
|||||||
{/* Dropdown Items */}
|
{/* Dropdown Items */}
|
||||||
|
|
||||||
{dropdownNavigation.map((link) => (
|
{dropdownNavigation.map((link) => (
|
||||||
<Link href={link.href} target={link.target} className="flex w-full items-center">
|
<Link id={link.href} href={link.href} target={link.target} className="flex w-full items-center">
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
<link.icon className="mr-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
{link.label}
|
{link.label}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { getEnvironments } from "@/lib/environment/service";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
|
|
||||||
const LandingLayout = async (props) => {
|
const LandingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|||||||
@@ -1,27 +1,25 @@
|
|||||||
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getOrganizationsByUserId } from "@/lib/organization/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getOrganization, getOrganizationsByUserId } from "@formbricks/lib/organization/service";
|
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session || !session.user) {
|
const { session, organization } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
const user = await getUser(session.user.id);
|
||||||
if (!user) return notFound();
|
if (!user) return notFound();
|
||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
|
||||||
if (!organization) return notFound();
|
|
||||||
|
|
||||||
const organizations = await getOrganizationsByUserId(session.user.id);
|
const organizations = await getOrganizationsByUserId(session.user.id);
|
||||||
|
|
||||||
const { features } = await getEnterpriseLicense();
|
const { features } = await getEnterpriseLicense();
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import ProjectOnboardingLayout from "./layout";
|
||||||
|
|
||||||
|
// Mock all the modules and functions that this layout uses:
|
||||||
|
|
||||||
|
vi.mock("@/lib/constants", () => ({
|
||||||
|
IS_FORMBRICKS_CLOUD: false,
|
||||||
|
POSTHOG_API_KEY: "mock-posthog-api-key",
|
||||||
|
POSTHOG_HOST: "mock-posthog-host",
|
||||||
|
IS_POSTHOG_CONFIGURED: true,
|
||||||
|
ENCRYPTION_KEY: "mock-encryption-key",
|
||||||
|
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||||
|
GITHUB_ID: "mock-github-id",
|
||||||
|
GITHUB_SECRET: "test-githubID",
|
||||||
|
GOOGLE_CLIENT_ID: "test-google-client-id",
|
||||||
|
GOOGLE_CLIENT_SECRET: "test-google-client-secret",
|
||||||
|
AZUREAD_CLIENT_ID: "test-azuread-client-id",
|
||||||
|
AZUREAD_CLIENT_SECRET: "test-azure",
|
||||||
|
AZUREAD_TENANT_ID: "test-azuread-tenant-id",
|
||||||
|
OIDC_DISPLAY_NAME: "test-oidc-display-name",
|
||||||
|
OIDC_CLIENT_ID: "test-oidc-client-id",
|
||||||
|
OIDC_ISSUER: "test-oidc-issuer",
|
||||||
|
OIDC_CLIENT_SECRET: "test-oidc-client-secret",
|
||||||
|
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||||
|
WEBAPP_URL: "test-webapp-url",
|
||||||
|
IS_PRODUCTION: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next-auth", () => ({
|
||||||
|
getServerSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/organization/auth", () => ({
|
||||||
|
canUserAccessOrganization: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/organization/service", () => ({
|
||||||
|
getOrganization: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/user/service", () => ({
|
||||||
|
getUser: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/tolgee/server", () => ({
|
||||||
|
getTranslate: vi.fn(() => {
|
||||||
|
// Return a mock translator that just returns the key
|
||||||
|
return (key: string) => key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// mock the child components
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/components/PosthogIdentify", () => ({
|
||||||
|
PosthogIdentify: () => <div data-testid="posthog-identify" />,
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||||
|
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ProjectOnboardingLayout", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("redirects to /auth/login if there is no session", async () => {
|
||||||
|
// Mock no session
|
||||||
|
vi.mocked(getServerSession).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const layoutElement = await ProjectOnboardingLayout({
|
||||||
|
params: { organizationId: "org-123" },
|
||||||
|
children: <div data-testid="child-content">Hello!</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(redirect).toHaveBeenCalledWith("/auth/login");
|
||||||
|
// Layout returns nothing after redirect
|
||||||
|
expect(layoutElement).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an error if user does not exist", async () => {
|
||||||
|
vi.mocked(getServerSession).mockResolvedValueOnce({
|
||||||
|
user: { id: "user-123" },
|
||||||
|
});
|
||||||
|
vi.mocked(getUser).mockResolvedValueOnce(null); // no user in DB
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ProjectOnboardingLayout({
|
||||||
|
params: { organizationId: "org-123" },
|
||||||
|
children: <div data-testid="child-content">Hello!</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.user_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws AuthorizationError if user cannot access organization", async () => {
|
||||||
|
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||||
|
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
||||||
|
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ProjectOnboardingLayout({
|
||||||
|
params: { organizationId: "org-123" },
|
||||||
|
children: <div data-testid="child-content">Child</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.not_authorized");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an error if organization does not exist", async () => {
|
||||||
|
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||||
|
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123" } as TUser);
|
||||||
|
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
|
||||||
|
vi.mocked(getOrganization).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ProjectOnboardingLayout({
|
||||||
|
params: { organizationId: "org-123" },
|
||||||
|
children: <div data-testid="child-content">Hello!</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.organization_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders child content plus PosthogIdentify & ToasterClient if everything is valid", async () => {
|
||||||
|
// Provide valid data
|
||||||
|
vi.mocked(getServerSession).mockResolvedValueOnce({ user: { id: "user-123" } });
|
||||||
|
vi.mocked(getUser).mockResolvedValueOnce({ id: "user-123", name: "Test User" } as TUser);
|
||||||
|
vi.mocked(canUserAccessOrganization).mockResolvedValueOnce(true);
|
||||||
|
vi.mocked(getOrganization).mockResolvedValueOnce({
|
||||||
|
id: "org-123",
|
||||||
|
name: "Test Org",
|
||||||
|
billing: {
|
||||||
|
plan: "enterprise",
|
||||||
|
},
|
||||||
|
} as TOrganization);
|
||||||
|
|
||||||
|
let layoutElement: React.ReactNode;
|
||||||
|
// Because it's an async server component, do it in an act
|
||||||
|
await act(async () => {
|
||||||
|
layoutElement = await ProjectOnboardingLayout({
|
||||||
|
params: { organizationId: "org-123" },
|
||||||
|
children: <div data-testid="child-content">Hello!</div>,
|
||||||
|
});
|
||||||
|
render(layoutElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello!");
|
||||||
|
expect(screen.getByTestId("posthog-identify")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
||||||
|
import { IS_POSTHOG_CONFIGURED } from "@/lib/constants";
|
||||||
|
import { canUserAccessOrganization } from "@/lib/organization/auth";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
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";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { canUserAccessOrganization } from "@formbricks/lib/organization/auth";
|
|
||||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { AuthorizationError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
const ProjectOnboardingLayout = async (props) => {
|
const ProjectOnboardingLayout = async (props) => {
|
||||||
@@ -16,7 +17,8 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
if (!session || !session.user) {
|
|
||||||
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,8 +28,9 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
const isAuthorized = await canUserAccessOrganization(session.user.id, params.organizationId);
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
throw AuthorizationError;
|
throw new AuthorizationError(t("common.not_authorized"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
const organization = await getOrganization(params.organizationId);
|
||||||
@@ -43,6 +46,7 @@ const ProjectOnboardingLayout = async (props) => {
|
|||||||
organizationId={organization.id}
|
organizationId={organization.id}
|
||||||
organizationName={organization.name}
|
organizationName={organization.name}
|
||||||
organizationBilling={organization.billing}
|
organizationBilling={organization.billing}
|
||||||
|
isPosthogEnabled={IS_POSTHOG_CONFIGURED}
|
||||||
/>
|
/>
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
|
|
||||||
interface ChannelPageProps {
|
interface ChannelPageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -17,8 +16,10 @@ interface ChannelPageProps {
|
|||||||
|
|
||||||
const Page = async (props: ChannelPageProps) => {
|
const Page = async (props: ChannelPageProps) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session || !session.user) {
|
const { session } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getOrganizationProjectsCount } from "@/lib/project/service";
|
||||||
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";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
|
||||||
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
|
|
||||||
|
|
||||||
const OnboardingLayout = async (props) => {
|
const OnboardingLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
|
|
||||||
interface ModePageProps {
|
interface ModePageProps {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -17,8 +16,10 @@ interface ModePageProps {
|
|||||||
|
|
||||||
const Page = async (props: ModePageProps) => {
|
const Page = async (props: ModePageProps) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session || !session.user) {
|
const { session } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
|||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
|
||||||
import { previewSurvey } from "@/app/lib/templates";
|
import { previewSurvey } from "@/app/lib/templates";
|
||||||
|
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
|
||||||
@@ -26,7 +27,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@formbricks/lib/localStorage";
|
|
||||||
import {
|
import {
|
||||||
TProjectConfigChannel,
|
TProjectConfigChannel,
|
||||||
TProjectConfigIndustry,
|
TProjectConfigIndustry,
|
||||||
@@ -225,12 +225,13 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<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">
|
<div className="z-0 h-3/4 w-3/4">
|
||||||
<SurveyInline
|
<SurveyInline
|
||||||
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
styling={{ brandColor: { light: brandColor } }}
|
styling={{ brandColor: { light: brandColor } }}
|
||||||
isBrandingEnabled={false}
|
isBrandingEnabled={false}
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getRoleManagementPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
|
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Header } from "@/modules/ui/components/header";
|
import { Header } from "@/modules/ui/components/header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
|
||||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
|
||||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
|
|
||||||
interface ProjectSettingsPageProps {
|
interface ProjectSettingsPageProps {
|
||||||
@@ -29,25 +27,20 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
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);
|
|
||||||
|
|
||||||
if (!session || !session.user) {
|
const { session, organization } = await getOrganizationAuth(params.organizationId);
|
||||||
|
|
||||||
|
if (!session?.user) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = searchParams.channel || null;
|
const channel = searchParams.channel ?? null;
|
||||||
const industry = searchParams.industry || null;
|
const industry = searchParams.industry ?? null;
|
||||||
const mode = searchParams.mode || "surveys";
|
const mode = searchParams.mode ?? "surveys";
|
||||||
const projects = await getUserProjects(session.user.id, params.organizationId);
|
const projects = await getUserProjects(session.user.id, params.organizationId);
|
||||||
|
|
||||||
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
|
||||||
|
|
||||||
const organization = await getOrganization(params.organizationId);
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
const canDoRoleManagement = await getRoleManagementPermission(organization.billing.plan);
|
||||||
|
|
||||||
if (!organizationTeams) {
|
if (!organizationTeams) {
|
||||||
@@ -72,7 +65,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import SurveyEditorEnvironmentLayout from "./layout";
|
||||||
|
|
||||||
|
// Mock sub-components to render identifiable elements
|
||||||
|
vi.mock("@/modules/ui/components/environmentId-base-layout", () => ({
|
||||||
|
EnvironmentIdBaseLayout: ({ children, environmentId }: any) => (
|
||||||
|
<div data-testid="EnvironmentIdBaseLayout">
|
||||||
|
{environmentId}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/ui/components/dev-environment-banner", () => ({
|
||||||
|
DevEnvironmentBanner: ({ environment }: any) => (
|
||||||
|
<div data-testid="DevEnvironmentBanner">{environment.id}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mocks for dependencies
|
||||||
|
vi.mock("@/modules/environments/lib/utils", () => ({
|
||||||
|
environmentIdLayoutChecks: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/lib/environment/service", () => ({
|
||||||
|
getEnvironment: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("SurveyEditorEnvironmentLayout", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders successfully when environment is found", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any, // Mock translation function, we don't need to implement it for the test
|
||||||
|
session: { user: { id: "user1" } } as Session,
|
||||||
|
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
vi.mocked(getEnvironment).mockResolvedValueOnce({ id: "env1" } as TEnvironment);
|
||||||
|
|
||||||
|
const result = await SurveyEditorEnvironmentLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div data-testid="child">Survey Editor Content</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(result);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("EnvironmentIdBaseLayout")).toHaveTextContent("env1");
|
||||||
|
expect(screen.getByTestId("DevEnvironmentBanner")).toHaveTextContent("env1");
|
||||||
|
expect(screen.getByTestId("child")).toHaveTextContent("Survey Editor Content");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws an error when environment is not found", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any,
|
||||||
|
session: { user: { id: "user1" } } as Session,
|
||||||
|
user: { id: "user1", email: "user1@example.com" } as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
vi.mocked(getEnvironment).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
SurveyEditorEnvironmentLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div>Content</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.environment_not_found");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("calls redirect when session is null", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any,
|
||||||
|
session: undefined as unknown as Session,
|
||||||
|
user: undefined as unknown as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Redirect called");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
SurveyEditorEnvironmentLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div>Content</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Redirect called");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error if user is null", async () => {
|
||||||
|
vi.mocked(environmentIdLayoutChecks).mockResolvedValueOnce({
|
||||||
|
t: ((key: string) => key) as any,
|
||||||
|
session: { user: { id: "user1" } } as Session,
|
||||||
|
user: undefined as unknown as TUser,
|
||||||
|
organization: { id: "org1", name: "Org1", billing: {} } as TOrganization,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(redirect).mockImplementationOnce(() => {
|
||||||
|
throw new Error("Redirect called");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
SurveyEditorEnvironmentLayout({
|
||||||
|
params: Promise.resolve({ environmentId: "env1" }),
|
||||||
|
children: <div>Content</div>,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("common.user_not_found");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,44 +1,24 @@
|
|||||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
import { getEnvironment } from "@/lib/environment/service";
|
||||||
import { PosthogIdentify } from "@/app/(app)/environments/[environmentId]/components/PosthogIdentify";
|
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
|
||||||
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
import { EnvironmentIdBaseLayout } from "@/modules/ui/components/environmentId-base-layout";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
|
||||||
|
|
||||||
const SurveyEditorEnvironmentLayout = async (props) => {
|
const SurveyEditorEnvironmentLayout = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
|
|
||||||
const { children } = props;
|
const { children } = props;
|
||||||
|
|
||||||
const t = await getTranslate();
|
const { t, session, user, organization } = await environmentIdLayoutChecks(params.environmentId);
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session || !session.user) {
|
if (!session) {
|
||||||
return redirect(`/auth/login`);
|
return redirect(`/auth/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error(t("common.user_not_found"));
|
throw new Error(t("common.user_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAccess = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
|
|
||||||
if (!hasAccess) {
|
|
||||||
throw new AuthorizationError(t("common.not_authorized"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const organization = await getOrganizationByEnvironmentId(params.environmentId);
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const environment = await getEnvironment(params.environmentId);
|
const environment = await getEnvironment(params.environmentId);
|
||||||
|
|
||||||
if (!environment) {
|
if (!environment) {
|
||||||
@@ -46,24 +26,16 @@ const SurveyEditorEnvironmentLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<EnvironmentIdBaseLayout
|
||||||
<ResponseFilterProvider>
|
environmentId={params.environmentId}
|
||||||
<PosthogIdentify
|
session={session}
|
||||||
session={session}
|
user={user}
|
||||||
user={user}
|
organization={organization}>
|
||||||
environmentId={params.environmentId}
|
<div className="flex h-screen flex-col">
|
||||||
organizationId={organization.id}
|
<DevEnvironmentBanner environment={environment} />
|
||||||
organizationName={organization.name}
|
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
||||||
organizationBilling={organization.billing}
|
</div>
|
||||||
/>
|
</EnvironmentIdBaseLayout>
|
||||||
<FormbricksClient userId={user.id} email={user.email} />
|
|
||||||
<ToasterClient />
|
|
||||||
<div className="flex h-screen flex-col">
|
|
||||||
<DevEnvironmentBanner environment={environment} />
|
|
||||||
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
|
|
||||||
</div>
|
|
||||||
</ResponseFilterProvider>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
81
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal file
81
apps/web/app/(app)/components/FormbricksClient.test.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import formbricks from "@formbricks/js";
|
||||||
|
import { FormbricksClient } from "./FormbricksClient";
|
||||||
|
|
||||||
|
// Mock next/navigation hooks.
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/test-path",
|
||||||
|
useSearchParams: () => new URLSearchParams("foo=bar"),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the flag that enables Formbricks.
|
||||||
|
vi.mock("@/app/lib/formbricks", () => ({
|
||||||
|
formbricksEnabled: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Formbricks SDK module.
|
||||||
|
vi.mock("@formbricks/js", () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
setup: vi.fn(),
|
||||||
|
setUserId: vi.fn(),
|
||||||
|
setEmail: vi.fn(),
|
||||||
|
registerRouteChange: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("FormbricksClient", () => {
|
||||||
|
test("calls setup, setUserId, setEmail and registerRouteChange on mount when enabled", () => {
|
||||||
|
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||||
|
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||||
|
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||||
|
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormbricksClient
|
||||||
|
userId="user-123"
|
||||||
|
email="test@example.com"
|
||||||
|
formbricksEnvironmentId="env-test"
|
||||||
|
formbricksApiHost="https://api.test.com"
|
||||||
|
formbricksEnabled={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expect the first effect to call setup and assign the provided user details.
|
||||||
|
expect(mockSetup).toHaveBeenCalledWith({
|
||||||
|
environmentId: "env-test",
|
||||||
|
appUrl: "https://api.test.com",
|
||||||
|
});
|
||||||
|
expect(mockSetUserId).toHaveBeenCalledWith("user-123");
|
||||||
|
expect(mockSetEmail).toHaveBeenCalledWith("test@example.com");
|
||||||
|
|
||||||
|
// And the second effect should always register the route change when Formbricks is enabled.
|
||||||
|
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not call setup, setUserId, or setEmail if userId is not provided yet still calls registerRouteChange", () => {
|
||||||
|
const mockSetup = vi.spyOn(formbricks, "setup");
|
||||||
|
const mockSetUserId = vi.spyOn(formbricks, "setUserId");
|
||||||
|
const mockSetEmail = vi.spyOn(formbricks, "setEmail");
|
||||||
|
const mockRegisterRouteChange = vi.spyOn(formbricks, "registerRouteChange");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FormbricksClient
|
||||||
|
userId=""
|
||||||
|
email="test@example.com"
|
||||||
|
formbricksEnvironmentId="env-test"
|
||||||
|
formbricksApiHost="https://api.test.com"
|
||||||
|
formbricksEnabled={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Since userId is falsy, the first effect should not call setup or assign user details.
|
||||||
|
expect(mockSetup).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetUserId).not.toHaveBeenCalled();
|
||||||
|
expect(mockSetEmail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The second effect only checks formbricksEnabled, so registerRouteChange should be called.
|
||||||
|
expect(mockRegisterRouteChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,32 +1,44 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { formbricksEnabled } from "@/app/lib/formbricks";
|
|
||||||
import { usePathname, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import formbricks from "@formbricks/js";
|
import formbricks from "@formbricks/js";
|
||||||
import { env } from "@formbricks/lib/env";
|
|
||||||
|
|
||||||
export const FormbricksClient = ({ userId, email }: { userId: string; email: string }) => {
|
interface FormbricksClientProps {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
formbricksEnvironmentId?: string;
|
||||||
|
formbricksApiHost?: string;
|
||||||
|
formbricksEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FormbricksClient = ({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
formbricksEnvironmentId,
|
||||||
|
formbricksApiHost,
|
||||||
|
formbricksEnabled,
|
||||||
|
}: FormbricksClientProps) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formbricksEnabled && userId) {
|
if (formbricksEnabled && userId) {
|
||||||
formbricks.init({
|
formbricks.setup({
|
||||||
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
|
environmentId: formbricksEnvironmentId ?? "",
|
||||||
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
|
appUrl: formbricksApiHost ?? "",
|
||||||
userId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
formbricks.setUserId(userId);
|
||||||
formbricks.setEmail(email);
|
formbricks.setEmail(email);
|
||||||
}
|
}
|
||||||
}, [userId, email]);
|
}, [userId, email, formbricksEnvironmentId, formbricksApiHost, formbricksEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (formbricksEnabled) {
|
if (formbricksEnabled) {
|
||||||
formbricks.registerRouteChange();
|
formbricks.registerRouteChange();
|
||||||
}
|
}
|
||||||
}, [pathname, searchParams]);
|
}, [pathname, searchParams, formbricksEnabled]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export const LoadingCard = ({
|
export const LoadingCard = ({
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { getOrganization } from "@/lib/organization/service";
|
||||||
|
import { getOrganizationProjectsCount } from "@/lib/project/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-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||||
import {
|
import {
|
||||||
@@ -8,9 +11,6 @@ import {
|
|||||||
} from "@/modules/ee/license-check/lib/utils";
|
} from "@/modules/ee/license-check/lib/utils";
|
||||||
import { createProject } from "@/modules/projects/settings/lib/project";
|
import { createProject } from "@/modules/projects/settings/lib/project";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getOrganization } from "@formbricks/lib/organization/service";
|
|
||||||
import { getOrganizationProjectsCount } from "@formbricks/lib/project/service";
|
|
||||||
import { updateUser } from "@formbricks/lib/user/service";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||||
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
import { ZProjectUpdateInput } from "@formbricks/types/project";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { deleteActionClass, getActionClass, updateActionClass } from "@/lib/actionClass/service";
|
||||||
|
import { cache } from "@/lib/cache";
|
||||||
|
import { getSurveysByActionClassId } from "@/lib/survey/service";
|
||||||
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
import { actionClient, authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||||
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromActionClassId, getProjectIdFromActionClassId } from "@/lib/utils/helper";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
|
||||||
import { cache } from "@formbricks/lib/cache";
|
|
||||||
import { getSurveysByActionClassId } from "@formbricks/lib/survey/service";
|
|
||||||
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
import { ZActionClassInput } from "@formbricks/types/action-classes";
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
|
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
|
||||||
|
import { convertDateTimeStringShort } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||||
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
import { createActionClassAction } from "@/modules/survey/editor/actions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||||
@@ -10,8 +12,6 @@ import { LoadingSpinner } from "@/modules/ui/components/loading-spinner";
|
|||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
|
||||||
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
import { TActionClass, TActionClassInput, TActionClassInputCode } from "@formbricks/types/action-classes";
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { getActiveInactiveSurveysAction } from "../actions";
|
import { getActiveInactiveSurveysAction } from "../actions";
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
|
import { ACTION_TYPE_ICON_LOOKUP } from "@/app/(app)/environments/[environmentId]/actions/utils";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { TActionClass } from "@formbricks/types/action-classes";
|
import { TActionClass } from "@formbricks/types/action-classes";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export const ActionClassDataRow = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
{timeSince(actionClass.createdAt.toString(), locale)}
|
{timeSince(actionClass.createdAt.toString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
|
|||||||
@@ -2,22 +2,15 @@ import { ActionClassesTable } from "@/app/(app)/environments/[environmentId]/act
|
|||||||
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
|
import { ActionClassDataRow } from "@/app/(app)/environments/[environmentId]/actions/components/ActionRowData";
|
||||||
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
|
import { ActionTableHeading } from "@/app/(app)/environments/[environmentId]/actions/components/ActionTableHeading";
|
||||||
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
import { AddActionModal } from "@/app/(app)/environments/[environmentId]/actions/components/AddActionModal";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getActionClasses } from "@/lib/actionClass/service";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getEnvironments } from "@/lib/environment/service";
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
|
||||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
|
||||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Actions",
|
title: "Actions",
|
||||||
@@ -25,51 +18,24 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
|
const { isReadOnly, project, isBilling, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const [actionClasses, organization, project] = await Promise.all([
|
|
||||||
getActionClasses(params.environmentId),
|
const [actionClasses] = await Promise.all([getActionClasses(params.environmentId)]);
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
|
||||||
getProjectByEnvironmentId(params.environmentId),
|
|
||||||
]);
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const environments = await getEnvironments(project.id);
|
const environments = await getEnvironments(project.id);
|
||||||
const currentEnvironment = environments.find((env) => env.id === params.environmentId);
|
|
||||||
|
|
||||||
if (!currentEnvironment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
const otherEnvironment = environments.filter((env) => env.id !== params.environmentId)[0];
|
||||||
|
|
||||||
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
const otherEnvActionClasses = await getActionClasses(otherEnvironment.id);
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
|
||||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session?.user.id, project.id);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
const renderAddActionButton = () => (
|
const renderAddActionButton = () => (
|
||||||
<AddActionModal
|
<AddActionModal
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
@@ -82,7 +48,7 @@ const Page = async (props) => {
|
|||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
<PageHeader pageTitle={t("common.actions")} cta={!isReadOnly ? renderAddActionButton() : undefined} />
|
||||||
<ActionClassesTable
|
<ActionClassesTable
|
||||||
environment={currentEnvironment}
|
environment={environment}
|
||||||
otherEnvironment={otherEnvironment}
|
otherEnvironment={otherEnvironment}
|
||||||
otherEnvActionClasses={otherEnvActionClasses}
|
otherEnvActionClasses={otherEnvActionClasses}
|
||||||
environmentId={params.environmentId}
|
environmentId={params.environmentId}
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
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 { getEnvironment, getEnvironments } from "@/lib/environment/service";
|
||||||
|
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import {
|
||||||
|
getMonthlyActiveOrganizationPeopleCount,
|
||||||
|
getMonthlyOrganizationResponseCount,
|
||||||
|
getOrganizationByEnvironmentId,
|
||||||
|
getOrganizationsByUserId,
|
||||||
|
} from "@/lib/organization/service";
|
||||||
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
|
import { getUser } from "@/lib/user/service";
|
||||||
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getEnterpriseLicense, getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||||
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
import { DevEnvironmentBanner } from "@/modules/ui/components/dev-environment-banner";
|
||||||
@@ -7,18 +19,6 @@ import { LimitsReachedBanner } from "@/modules/ui/components/limits-reached-bann
|
|||||||
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
import { PendingDowngradeBanner } from "@/modules/ui/components/pending-downgrade-banner";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
|
||||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import {
|
|
||||||
getMonthlyActiveOrganizationPeopleCount,
|
|
||||||
getMonthlyOrganizationResponseCount,
|
|
||||||
getOrganizationByEnvironmentId,
|
|
||||||
getOrganizationsByUserId,
|
|
||||||
} from "@formbricks/lib/organization/service";
|
|
||||||
import { getUserProjects } from "@formbricks/lib/project/service";
|
|
||||||
import { getUser } from "@formbricks/lib/user/service";
|
|
||||||
|
|
||||||
interface EnvironmentLayoutProps {
|
interface EnvironmentLayoutProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
@@ -111,6 +111,7 @@ export const EnvironmentLayout = async ({ environmentId, session, children }: En
|
|||||||
organizationProjectsLimit={organizationProjectsLimit}
|
organizationProjectsLimit={organizationProjectsLimit}
|
||||||
user={user}
|
user={user}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||||
isLicenseActive={active}
|
isLicenseActive={active}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@formbricks/lib/localStorage";
|
|
||||||
|
|
||||||
interface EnvironmentStorageHandlerProps {
|
interface EnvironmentStorageHandlerProps {
|
||||||
environmentId: string;
|
environmentId: string;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
import { Label } from "@/modules/ui/components/label";
|
import { Label } from "@/modules/ui/components/label";
|
||||||
import { Switch } from "@/modules/ui/components/switch";
|
import { Switch } from "@/modules/ui/components/switch";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
|
||||||
interface EnvironmentSwitchProps {
|
interface EnvironmentSwitchProps {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { getLatestStableFbReleaseAction } from "@/app/(app)/environments/[enviro
|
|||||||
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
import { NavigationLink } from "@/app/(app)/environments/[environmentId]/components/NavigationLink";
|
||||||
import { formbricksLogout } from "@/app/lib/formbricks";
|
import { formbricksLogout } from "@/app/lib/formbricks";
|
||||||
import FBLogo from "@/images/formbricks-wordmark.svg";
|
import FBLogo from "@/images/formbricks-wordmark.svg";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
|
import { capitalizeFirstLetter } from "@/lib/utils/strings";
|
||||||
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
import { CreateOrganizationModal } from "@/modules/organization/components/CreateOrganizationModal";
|
||||||
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
import { ProjectSwitcher } from "@/modules/projects/components/project-switcher";
|
||||||
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
import { ProfileAvatar } from "@/modules/ui/components/avatars";
|
||||||
@@ -45,9 +48,6 @@ 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 { useEffect, useMemo, useState } from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { capitalizeFirstLetter } from "@formbricks/lib/utils/strings";
|
|
||||||
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";
|
||||||
@@ -63,6 +63,7 @@ interface NavigationProps {
|
|||||||
projects: TProject[];
|
projects: TProject[];
|
||||||
isMultiOrgEnabled: boolean;
|
isMultiOrgEnabled: boolean;
|
||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
organizationProjectsLimit: number;
|
organizationProjectsLimit: number;
|
||||||
isLicenseActive: boolean;
|
isLicenseActive: boolean;
|
||||||
@@ -79,6 +80,7 @@ export const MainNavigation = ({
|
|||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
organizationProjectsLimit,
|
organizationProjectsLimit,
|
||||||
isLicenseActive,
|
isLicenseActive,
|
||||||
|
isDevelopment,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -263,7 +265,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:outline-none focus:ring-0 focus:ring-transparent"
|
"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"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
@@ -296,7 +298,7 @@ export const MainNavigation = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
{/* New Version Available */}
|
{/* New Version Available */}
|
||||||
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && (
|
{!isCollapsed && isOwnerOrManager && latestVersion && !isFormbricksCloud && !isDevelopment && (
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/formbricks/formbricks/releases"
|
href="https://github.com/formbricks/formbricks/releases"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { cn } from "@/lib/cn";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
|
|
||||||
interface NavigationLinkProps {
|
interface NavigationLinkProps {
|
||||||
href: string;
|
href: string;
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import "@testing-library/jest-dom/vitest";
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { Session } from "next-auth";
|
||||||
|
import { usePostHog } from "posthog-js/react";
|
||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||||
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
import { PosthogIdentify } from "./PosthogIdentify";
|
||||||
|
|
||||||
|
type PartialPostHog = Partial<ReturnType<typeof usePostHog>>;
|
||||||
|
|
||||||
|
vi.mock("posthog-js/react", () => ({
|
||||||
|
usePostHog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("PosthogIdentify", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identifies the user and sets groups when isPosthogEnabled is true", () => {
|
||||||
|
const mockIdentify = vi.fn();
|
||||||
|
const mockGroup = vi.fn();
|
||||||
|
|
||||||
|
const mockPostHog: PartialPostHog = {
|
||||||
|
identify: mockIdentify,
|
||||||
|
group: mockGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PosthogIdentify
|
||||||
|
session={{ user: { id: "user-123" } } as Session}
|
||||||
|
user={
|
||||||
|
{
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "engineer",
|
||||||
|
objective: "increase_conversion",
|
||||||
|
} as TUser
|
||||||
|
}
|
||||||
|
environmentId="env-456"
|
||||||
|
organizationId="org-789"
|
||||||
|
organizationName="Test Org"
|
||||||
|
organizationBilling={
|
||||||
|
{
|
||||||
|
plan: "enterprise",
|
||||||
|
limits: { monthly: { responses: 1000, miu: 5000 }, projects: 10 },
|
||||||
|
} as TOrganizationBilling
|
||||||
|
}
|
||||||
|
isPosthogEnabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// verify that identify is called with the session user id + extra info
|
||||||
|
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
role: "engineer",
|
||||||
|
objective: "increase_conversion",
|
||||||
|
});
|
||||||
|
|
||||||
|
// environment + organization groups
|
||||||
|
expect(mockGroup).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockGroup).toHaveBeenCalledWith("environment", "env-456", { name: "env-456" });
|
||||||
|
expect(mockGroup).toHaveBeenCalledWith("organization", "org-789", {
|
||||||
|
name: "Test Org",
|
||||||
|
plan: "enterprise",
|
||||||
|
responseLimit: 1000,
|
||||||
|
miuLimit: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing if isPosthogEnabled is false", () => {
|
||||||
|
const mockIdentify = vi.fn();
|
||||||
|
const mockGroup = vi.fn();
|
||||||
|
|
||||||
|
const mockPostHog: PartialPostHog = {
|
||||||
|
identify: mockIdentify,
|
||||||
|
group: mockGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PosthogIdentify
|
||||||
|
session={{ user: { id: "user-123" } } as Session}
|
||||||
|
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
||||||
|
isPosthogEnabled={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockIdentify).not.toHaveBeenCalled();
|
||||||
|
expect(mockGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing if session user is missing", () => {
|
||||||
|
const mockIdentify = vi.fn();
|
||||||
|
const mockGroup = vi.fn();
|
||||||
|
|
||||||
|
const mockPostHog: PartialPostHog = {
|
||||||
|
identify: mockIdentify,
|
||||||
|
group: mockGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PosthogIdentify
|
||||||
|
// no user in session
|
||||||
|
session={{} as any}
|
||||||
|
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
||||||
|
isPosthogEnabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Because there's no session.user, we skip identify
|
||||||
|
expect(mockIdentify).not.toHaveBeenCalled();
|
||||||
|
expect(mockGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("identifies user but does not group if environmentId/organizationId not provided", () => {
|
||||||
|
const mockIdentify = vi.fn();
|
||||||
|
const mockGroup = vi.fn();
|
||||||
|
|
||||||
|
const mockPostHog: PartialPostHog = {
|
||||||
|
identify: mockIdentify,
|
||||||
|
group: mockGroup,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(usePostHog).mockReturnValue(mockPostHog as ReturnType<typeof usePostHog>);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PosthogIdentify
|
||||||
|
session={{ user: { id: "user-123" } } as Session}
|
||||||
|
user={{ name: "Test User", email: "test@example.com" } as TUser}
|
||||||
|
isPosthogEnabled
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockIdentify).toHaveBeenCalledWith("user-123", {
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
role: undefined,
|
||||||
|
objective: undefined,
|
||||||
|
});
|
||||||
|
// No environmentId or organizationId => no group calls
|
||||||
|
expect(mockGroup).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,12 +3,9 @@
|
|||||||
import type { Session } from "next-auth";
|
import type { Session } from "next-auth";
|
||||||
import { usePostHog } from "posthog-js/react";
|
import { usePostHog } from "posthog-js/react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { env } from "@formbricks/lib/env";
|
|
||||||
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
import { TOrganizationBilling } from "@formbricks/types/organizations";
|
||||||
import { TUser } from "@formbricks/types/user";
|
import { TUser } from "@formbricks/types/user";
|
||||||
|
|
||||||
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
|
|
||||||
|
|
||||||
interface PosthogIdentifyProps {
|
interface PosthogIdentifyProps {
|
||||||
session: Session;
|
session: Session;
|
||||||
user: TUser;
|
user: TUser;
|
||||||
@@ -16,6 +13,7 @@ interface PosthogIdentifyProps {
|
|||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
organizationName?: string;
|
organizationName?: string;
|
||||||
organizationBilling?: TOrganizationBilling;
|
organizationBilling?: TOrganizationBilling;
|
||||||
|
isPosthogEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PosthogIdentify = ({
|
export const PosthogIdentify = ({
|
||||||
@@ -25,11 +23,12 @@ export const PosthogIdentify = ({
|
|||||||
organizationId,
|
organizationId,
|
||||||
organizationName,
|
organizationName,
|
||||||
organizationBilling,
|
organizationBilling,
|
||||||
|
isPosthogEnabled,
|
||||||
}: PosthogIdentifyProps) => {
|
}: PosthogIdentifyProps) => {
|
||||||
const posthog = usePostHog();
|
const posthog = usePostHog();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (posthogEnabled && session.user && posthog) {
|
if (isPosthogEnabled && session.user && posthog) {
|
||||||
posthog.identify(session.user.id, {
|
posthog.identify(session.user.id, {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@@ -59,6 +58,7 @@ export const PosthogIdentify = ({
|
|||||||
user.email,
|
user.email,
|
||||||
user.role,
|
user.role,
|
||||||
user.objective,
|
user.objective,
|
||||||
|
isPosthogEnabled,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox";
|
||||||
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
import { QuestionFilterOptions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/ResponseFilter";
|
||||||
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
import { getTodayDate } from "@/app/lib/surveys/surveys";
|
||||||
import { createContext, useCallback, useContext, useState } from "react";
|
import React, { createContext, useCallback, useContext, useState } from "react";
|
||||||
|
|
||||||
export interface FilterValue {
|
export interface FilterValue {
|
||||||
questionType: Partial<QuestionOption>;
|
questionType: Partial<QuestionOption>;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
import { TopControlButtons } from "@/app/(app)/environments/[environmentId]/components/TopControlButtons";
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ export const TopControlBar = ({
|
|||||||
<TopControlButtons
|
<TopControlButtons
|
||||||
environment={environment}
|
environment={environment}
|
||||||
environments={environments}
|
environments={environments}
|
||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
|
||||||
membershipRole={membershipRole}
|
membershipRole={membershipRole}
|
||||||
projectPermission={projectPermission}
|
projectPermission={projectPermission}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,22 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
|
import { EnvironmentSwitch } from "@/app/(app)/environments/[environmentId]/components/EnvironmentSwitch";
|
||||||
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { CircleUserIcon, MessageCircleQuestionIcon, PlusIcon } from "lucide-react";
|
import { BugIcon, CircleUserIcon, PlusIcon } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import formbricks from "@formbricks/js";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||||
|
|
||||||
interface TopControlButtonsProps {
|
interface TopControlButtonsProps {
|
||||||
environment: TEnvironment;
|
environment: TEnvironment;
|
||||||
environments: TEnvironment[];
|
environments: TEnvironment[];
|
||||||
isFormbricksCloud: boolean;
|
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
projectPermission: TTeamPermission | null;
|
projectPermission: TTeamPermission | null;
|
||||||
}
|
}
|
||||||
@@ -24,7 +23,6 @@ interface TopControlButtonsProps {
|
|||||||
export const TopControlButtons = ({
|
export const TopControlButtons = ({
|
||||||
environment,
|
environment,
|
||||||
environments,
|
environments,
|
||||||
isFormbricksCloud,
|
|
||||||
membershipRole,
|
membershipRole,
|
||||||
projectPermission,
|
projectPermission,
|
||||||
}: TopControlButtonsProps) => {
|
}: TopControlButtonsProps) => {
|
||||||
@@ -38,19 +36,15 @@ export const TopControlButtons = ({
|
|||||||
return (
|
return (
|
||||||
<div className="z-50 flex items-center space-x-2">
|
<div className="z-50 flex items-center space-x-2">
|
||||||
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
{!isBilling && <EnvironmentSwitch environment={environment} environments={environments} />}
|
||||||
{isFormbricksCloud && (
|
|
||||||
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
<TooltipRenderer tooltipContent={t("common.share_feedback")}>
|
||||||
<Button
|
<Button variant="ghost" size="icon" className="h-fit w-fit bg-slate-50 p-1" asChild>
|
||||||
variant="ghost"
|
<Link href="https://github.com/formbricks/formbricks/issues" target="_blank">
|
||||||
size="icon"
|
<BugIcon />
|
||||||
className="h-fit w-fit bg-slate-50 p-1"
|
</Link>
|
||||||
onClick={() => {
|
</Button>
|
||||||
formbricks.track("Top Menu: Product Feedback");
|
</TooltipRenderer>
|
||||||
}}>
|
|
||||||
<MessageCircleQuestionIcon />
|
|
||||||
</Button>
|
|
||||||
</TooltipRenderer>
|
|
||||||
)}
|
|
||||||
<TooltipRenderer tooltipContent={t("common.account")}>
|
<TooltipRenderer tooltipContent={t("common.account")}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { useTranslate } from "@tolgee/react";
|
||||||
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
import { AlertTriangleIcon, CheckIcon, RotateCcwIcon } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { cn } from "@formbricks/lib/cn";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
|
||||||
interface WidgetStatusIndicatorProps {
|
interface WidgetStatusIndicatorProps {
|
||||||
@@ -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-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance 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 />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { createOrUpdateIntegration, deleteIntegration } from "@/lib/integration/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,6 @@ import {
|
|||||||
getProjectIdFromIntegrationId,
|
getProjectIdFromIntegrationId,
|
||||||
} from "@/lib/utils/helper";
|
} from "@/lib/utils/helper";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { ZIntegrationInput } from "@formbricks/types/integration";
|
import { ZIntegrationInput } from "@formbricks/types/integration";
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[envir
|
|||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -23,8 +25,6 @@ import { useRouter } from "next/navigation";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
|
||||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import {
|
import {
|
||||||
TIntegrationAirtable,
|
TIntegrationAirtable,
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
|
||||||
|
import { ManageIntegration } from "./ManageIntegration";
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||||
|
deleteIntegrationAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock(
|
||||||
|
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
|
||||||
|
() => ({
|
||||||
|
AddIntegrationModal: ({ open, setOpenWithStates }) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="add-modal">
|
||||||
|
<button onClick={() => setOpenWithStates(false)}>close</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||||
|
DeleteDialog: ({ open, setOpen, onDelete }) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="delete-dialog">
|
||||||
|
<button onClick={onDelete}>confirm</button>
|
||||||
|
<button onClick={() => setOpen(false)}>cancel</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
environment: { id: "env1" } as TEnvironment,
|
||||||
|
environmentId: "env1",
|
||||||
|
setIsConnected: vi.fn(),
|
||||||
|
surveys: [],
|
||||||
|
airtableArray: [],
|
||||||
|
locale: "en-US" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ManageIntegration", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty state", () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
airtableIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||||
|
} as TIntegrationAirtable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("open add modal", async () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
airtableIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||||
|
} as TIntegrationAirtable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/link_new_table/));
|
||||||
|
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list integrations and open edit modal", async () => {
|
||||||
|
const item = {
|
||||||
|
baseId: "b",
|
||||||
|
tableId: "t",
|
||||||
|
surveyId: "s",
|
||||||
|
surveyName: "S",
|
||||||
|
tableName: "T",
|
||||||
|
questions: "Q",
|
||||||
|
questionIds: ["x"],
|
||||||
|
createdAt: new Date(),
|
||||||
|
includeVariables: false,
|
||||||
|
includeHiddenFields: false,
|
||||||
|
includeMetadata: false,
|
||||||
|
includeCreatedAt: false,
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
airtableIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
|
||||||
|
} as TIntegrationAirtable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("S")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("S"));
|
||||||
|
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration success", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
airtableIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||||
|
} as TIntegrationAirtable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||||
|
const { toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.success).toHaveBeenCalled();
|
||||||
|
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration error", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
airtableIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||||
|
} as TIntegrationAirtable
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
const { toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
AddIntegrationModal,
|
AddIntegrationModal,
|
||||||
IntegrationModalInputs,
|
IntegrationModalInputs,
|
||||||
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
|
} from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
@@ -13,7 +14,6 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
{integrationData.length ? (
|
{integrationData.length ? (
|
||||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||||
{tableHeaders.map((header, idx) => (
|
{tableHeaders.map((header) => (
|
||||||
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
|
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||||
{t(header)}
|
{t(header)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{integrationData.map((data, index) => (
|
{integrationData.map((data, index) => (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
|
||||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDefaultValues({
|
setDefaultValues({
|
||||||
base: data.baseId,
|
base: data.baseId,
|
||||||
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), props.locale)}
|
{timeSince(data.createdAt.toString(), props.locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
|
||||||
|
|
||||||
export const fetchTables = async (environmentId: string, baseId: string) => {
|
export const fetchTables = async (environmentId: string, baseId: string) => {
|
||||||
@@ -17,7 +18,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res.text);
|
const errorText = await res.text();
|
||||||
|
logger.error({ errorText }, "authorize: Could not fetch airtable config");
|
||||||
throw new Error("Could not create response");
|
throw new Error("Could not create response");
|
||||||
}
|
}
|
||||||
const resJSON = await res.json();
|
const resJSON = await res.json();
|
||||||
|
|||||||
@@ -1,22 +1,15 @@
|
|||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
import { GoBackButton } from "@/modules/ui/components/go-back-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 { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getAirtableTables } from "@formbricks/lib/airtable/service";
|
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
|
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
|
||||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
|
||||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
|
|
||||||
@@ -24,48 +17,25 @@ const Page = async (props) => {
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
const isEnabled = !!AIRTABLE_CLIENT_ID;
|
||||||
const [session, surveys, integrations, environment] = await Promise.all([
|
|
||||||
getServerSession(authOptions),
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getEnvironment(params.environmentId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
const airtableIntegration: TIntegrationAirtable | undefined = integrations?.find(
|
||||||
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
|
(integration): integration is TIntegrationAirtable => integration.type === "airtable"
|
||||||
);
|
);
|
||||||
|
|
||||||
let airtableArray: TIntegrationItem[] = [];
|
let airtableArray: TIntegrationItem[] = [];
|
||||||
if (airtableIntegration && airtableIntegration.config.key) {
|
if (airtableIntegration?.config.key) {
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
airtableArray = await getAirtableTables(params.environmentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
|
||||||
session?.user.id,
|
|
||||||
project.organizationId
|
|
||||||
);
|
|
||||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,41 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
||||||
import { getServerSession } from "next-auth";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||||
import { getSpreadsheetNameById } from "@formbricks/lib/googleSheet/service";
|
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
import { AuthorizationError } from "@formbricks/types/errors";
|
import { z } from "zod";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
|
|
||||||
export async function getSpreadsheetNameByIdAction(
|
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||||
googleSheetIntegration: TIntegrationGoogleSheets,
|
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||||
environmentId: string,
|
environmentId: z.string(),
|
||||||
spreadsheetId: string
|
spreadsheetId: z.string(),
|
||||||
) {
|
});
|
||||||
const session = await getServerSession(authOptions);
|
|
||||||
if (!session) throw new AuthorizationError("Not authorized");
|
|
||||||
|
|
||||||
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
export const getSpreadsheetNameByIdAction = authenticatedActionClient
|
||||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
.schema(ZGetSpreadsheetNameByIdAction)
|
||||||
const integrationData = structuredClone(googleSheetIntegration);
|
.action(async ({ ctx, parsedInput }) => {
|
||||||
integrationData.config.data.forEach((data) => {
|
await checkAuthorizationUpdated({
|
||||||
data.createdAt = new Date(data.createdAt);
|
userId: ctx.user.id,
|
||||||
|
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||||
|
access: [
|
||||||
|
{
|
||||||
|
type: "organization",
|
||||||
|
roles: ["owner", "manager"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "projectTeam",
|
||||||
|
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||||
|
minPermission: "readWrite",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const integrationData = structuredClone(parsedInput.googleSheetIntegration);
|
||||||
|
integrationData.config.data.forEach((data) => {
|
||||||
|
data.createdAt = new Date(data.createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return await getSpreadsheetNameById(integrationData, parsedInput.spreadsheetId);
|
||||||
});
|
});
|
||||||
return await getSpreadsheetNameById(integrationData, spreadsheetId);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
@@ -20,8 +23,6 @@ import Image from "next/image";
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
|
||||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
@@ -115,11 +116,18 @@ export const AddIntegrationModal = ({
|
|||||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||||
}
|
}
|
||||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||||
const spreadsheetName = await getSpreadsheetNameByIdAction(
|
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||||
googleSheetIntegration,
|
googleSheetIntegration,
|
||||||
environmentId,
|
environmentId,
|
||||||
spreadsheetId
|
spreadsheetId,
|
||||||
);
|
});
|
||||||
|
|
||||||
|
if (!spreadsheetNameResponse?.data) {
|
||||||
|
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spreadsheetName = spreadsheetNameResponse.data;
|
||||||
|
|
||||||
setIsLinkingSheet(true);
|
setIsLinkingSheet(true);
|
||||||
integrationData.spreadsheetId = spreadsheetId;
|
integrationData.spreadsheetId = spreadsheetId;
|
||||||
@@ -247,7 +255,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
{replaceHeadlineRecall(selectedSurvey, "default")?.questions.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
|
import { ManageIntegration } from "./ManageIntegration";
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||||
|
deleteIntegrationAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-hot-toast", () => ({
|
||||||
|
default: { success: vi.fn(), error: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||||
|
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="delete-dialog">
|
||||||
|
<button onClick={onDelete}>confirm</button>
|
||||||
|
<button onClick={() => setOpen(false)}>cancel</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||||
|
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
environment: { id: "env1" } as TEnvironment,
|
||||||
|
setOpenAddIntegrationModal: vi.fn(),
|
||||||
|
setIsConnected: vi.fn(),
|
||||||
|
setSelectedIntegration: vi.fn(),
|
||||||
|
locale: "en-US" as const,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("ManageIntegration (Google Sheets)", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty state", () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
googleSheetIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] },
|
||||||
|
} as unknown as TIntegrationGoogleSheets
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("click link new sheet", async () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
googleSheetIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] },
|
||||||
|
} as unknown as TIntegrationGoogleSheets
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText(/link_new_sheet/));
|
||||||
|
|
||||||
|
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||||
|
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list integrations and open edit", async () => {
|
||||||
|
const item = {
|
||||||
|
spreadsheetId: "sid",
|
||||||
|
spreadsheetName: "SheetName",
|
||||||
|
surveyId: "s1",
|
||||||
|
surveyName: "Survey1",
|
||||||
|
questionIds: ["q1"],
|
||||||
|
questions: "Q",
|
||||||
|
createdAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
googleSheetIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [item] },
|
||||||
|
} as unknown as TIntegrationGoogleSheets
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("Survey1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("Survey1"));
|
||||||
|
|
||||||
|
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
|
||||||
|
...item,
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration success", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
googleSheetIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] },
|
||||||
|
} as unknown as TIntegrationGoogleSheets
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
|
||||||
|
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||||
|
|
||||||
|
const { default: toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||||
|
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration error", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
googleSheetIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { email: "a@b.com", data: [] },
|
||||||
|
} as unknown as TIntegrationGoogleSheets
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
|
||||||
|
const { default: toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
@@ -9,7 +10,6 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import {
|
import {
|
||||||
TIntegrationGoogleSheets,
|
TIntegrationGoogleSheets,
|
||||||
@@ -36,11 +36,10 @@ export const ManageIntegration = ({
|
|||||||
}: ManageIntegrationProps) => {
|
}: ManageIntegrationProps) => {
|
||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||||
const integrationArray = googleSheetIntegration
|
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
|
||||||
? googleSheetIntegration.config.data
|
if (googleSheetIntegration?.config.data) {
|
||||||
? googleSheetIntegration.config.data
|
integrationArray = googleSheetIntegration.config.data;
|
||||||
: []
|
}
|
||||||
: [];
|
|
||||||
const [isDeleting, setisDeleting] = useState(false);
|
const [isDeleting, setisDeleting] = useState(false);
|
||||||
|
|
||||||
const handleDeleteIntegration = async () => {
|
const handleDeleteIntegration = async () => {
|
||||||
@@ -112,9 +111,9 @@ export const ManageIntegration = ({
|
|||||||
{integrationArray &&
|
{integrationArray &&
|
||||||
integrationArray.map((data, index) => {
|
integrationArray.map((data, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||||
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editIntegration(index);
|
editIntegration(index);
|
||||||
}}>
|
}}>
|
||||||
@@ -124,7 +123,7 @@ export const ManageIntegration = ({
|
|||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), locale)}
|
{timeSince(data.createdAt.toString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
||||||
const res = await fetch(`${apiHost}/api/google-sheet`, {
|
const res = await fetch(`${apiHost}/api/google-sheet`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res.text);
|
const errorText = await res.text();
|
||||||
|
logger.error({ errorText }, "authorize: Could not fetch google sheet config");
|
||||||
throw new Error("Could not create response");
|
throw new Error("Could not create response");
|
||||||
}
|
}
|
||||||
const resJSON = await res.json();
|
const resJSON = await res.json();
|
||||||
|
|||||||
@@ -1,69 +1,39 @@
|
|||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
GOOGLE_SHEETS_REDIRECT_URL,
|
GOOGLE_SHEETS_REDIRECT_URL,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { getTranslate } from "@/tolgee/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
|
||||||
const [session, surveys, integrations, environment] = await Promise.all([
|
|
||||||
getServerSession(authOptions),
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const [surveys, integrations] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrations(params.environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getEnvironment(params.environmentId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
const googleSheetIntegration: TIntegrationGoogleSheets | undefined = integrations?.find(
|
||||||
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
(integration): integration is TIntegrationGoogleSheets => integration.type === "googleSheets"
|
||||||
);
|
);
|
||||||
|
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
|
||||||
session?.user.id,
|
|
||||||
project.organizationId
|
|
||||||
);
|
|
||||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import "server-only";
|
import "server-only";
|
||||||
|
import { cache } from "@/lib/cache";
|
||||||
|
import { surveyCache } from "@/lib/survey/cache";
|
||||||
|
import { selectSurvey } from "@/lib/survey/service";
|
||||||
|
import { transformPrismaSurvey } from "@/lib/survey/utils";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { cache as reactCache } from "react";
|
import { cache as reactCache } from "react";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { cache } from "@formbricks/lib/cache";
|
import { logger } from "@formbricks/logger";
|
||||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
|
||||||
import { selectSurvey } from "@formbricks/lib/survey/service";
|
|
||||||
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
|
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -34,7 +35,7 @@ export const getSurveys = reactCache(
|
|||||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
console.error(error);
|
logger.error({ error }, "getSurveys: Could not fetch surveys");
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
import { cache } from "@/lib/cache";
|
||||||
import { webhookCache } from "@/lib/cache/webhook";
|
import { webhookCache } from "@/lib/cache/webhook";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { Prisma, Webhook } from "@prisma/client";
|
import { Prisma, Webhook } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { cache } from "@formbricks/lib/cache";
|
|
||||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import {
|
|||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/integrations/notion/constants";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
import { getQuestionTypes } from "@/modules/survey/lib/questions";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
|
||||||
@@ -18,9 +21,6 @@ import Image from "next/image";
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
|
||||||
import { structuredClone } from "@formbricks/lib/pollyfills/structuredClone";
|
|
||||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
|
||||||
import { TIntegrationInput } from "@formbricks/types/integration";
|
import { TIntegrationInput } from "@formbricks/types/integration";
|
||||||
import {
|
import {
|
||||||
TIntegrationNotion,
|
TIntegrationNotion,
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import type {
|
||||||
|
TIntegrationNotion,
|
||||||
|
TIntegrationNotionConfig,
|
||||||
|
TIntegrationNotionConfigData,
|
||||||
|
TIntegrationNotionCredential,
|
||||||
|
} from "@formbricks/types/integration/notion";
|
||||||
|
import { ManageIntegration } from "./ManageIntegration";
|
||||||
|
|
||||||
|
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
|
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||||
|
deleteIntegrationAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("ManageIntegration", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
environment: {} as any,
|
||||||
|
locale: "en-US" as const,
|
||||||
|
setOpenAddIntegrationModal: vi.fn(),
|
||||||
|
setIsConnected: vi.fn(),
|
||||||
|
setSelectedIntegration: vi.fn(),
|
||||||
|
handleNotionAuthorization: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
test("shows empty state when no databases", () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...defaultProps}
|
||||||
|
notionIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: {
|
||||||
|
data: [] as TIntegrationNotionConfigData[],
|
||||||
|
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||||
|
} as TIntegrationNotionConfig,
|
||||||
|
} as TIntegrationNotion
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders list and handles clicks", async () => {
|
||||||
|
const data = [
|
||||||
|
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
|
||||||
|
] as unknown as TIntegrationNotionConfigData[];
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...defaultProps}
|
||||||
|
notionIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
|
||||||
|
} as TIntegrationNotion
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("S")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("S"));
|
||||||
|
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
|
||||||
|
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("update and link new buttons invoke handlers", async () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...defaultProps}
|
||||||
|
notionIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: {
|
||||||
|
data: [],
|
||||||
|
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||||
|
} as TIntegrationNotionConfig,
|
||||||
|
} as TIntegrationNotion
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
|
||||||
|
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
|
||||||
|
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
|
||||||
|
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
@@ -10,7 +11,6 @@ import { useTranslate } from "@tolgee/react";
|
|||||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
@@ -39,11 +39,11 @@ export const ManageIntegration = ({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||||
const [isDeleting, setisDeleting] = useState(false);
|
const [isDeleting, setisDeleting] = useState(false);
|
||||||
const integrationArray = notionIntegration
|
|
||||||
? notionIntegration.config.data
|
let integrationArray: TIntegrationNotionConfigData[] = [];
|
||||||
? notionIntegration.config.data
|
if (notionIntegration?.config.data) {
|
||||||
: []
|
integrationArray = notionIntegration.config.data;
|
||||||
: [];
|
}
|
||||||
|
|
||||||
const handleDeleteIntegration = async () => {
|
const handleDeleteIntegration = async () => {
|
||||||
setisDeleting(true);
|
setisDeleting(true);
|
||||||
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
|
|||||||
{integrationArray &&
|
{integrationArray &&
|
||||||
integrationArray.map((data, index) => {
|
integrationArray.map((data, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={`${index}-${data.databaseId}`}
|
||||||
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
|
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editIntegration(index);
|
editIntegration(index);
|
||||||
}}>
|
}}>
|
||||||
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
|
|||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), locale)}
|
{timeSince(data.createdAt.toString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ export const TYPE_MAPPING = {
|
|||||||
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
|
[TSurveyQuestionTypeEnum.Address]: ["rich_text"],
|
||||||
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
|
[TSurveyQuestionTypeEnum.Matrix]: ["rich_text"],
|
||||||
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
|
[TSurveyQuestionTypeEnum.Cal]: ["checkbox"],
|
||||||
|
[TSurveyQuestionTypeEnum.ContactInfo]: ["rich_text"],
|
||||||
|
[TSurveyQuestionTypeEnum.Ranking]: ["rich_text"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
export const UNSUPPORTED_TYPES_BY_NOTION = [
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
||||||
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
|
const res = await fetch(`${apiHost}/api/v1/integrations/notion`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res.text);
|
const errorText = await res.text();
|
||||||
|
logger.error({ errorText }, "authorize: Could not fetch notion config");
|
||||||
throw new Error("Could not create response");
|
throw new Error("Could not create response");
|
||||||
}
|
}
|
||||||
const resJSON = await res.json();
|
const resJSON = await res.json();
|
||||||
|
|||||||
@@ -1,28 +1,21 @@
|
|||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|
||||||
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
|
||||||
import { getTranslate } from "@/tolgee/server";
|
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import {
|
import {
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
NOTION_OAUTH_CLIENT_SECRET,
|
NOTION_OAUTH_CLIENT_SECRET,
|
||||||
NOTION_REDIRECT_URI,
|
NOTION_REDIRECT_URI,
|
||||||
WEBAPP_URL,
|
WEBAPP_URL,
|
||||||
} from "@formbricks/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { getIntegrationByType } from "@formbricks/lib/integration/service";
|
import { getNotionDatabases } from "@/lib/notion/service";
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getNotionDatabases } from "@formbricks/lib/notion/service";
|
import { GoBackButton } from "@/modules/ui/components/go-back-button";
|
||||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { getTranslate } from "@/tolgee/server";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
@@ -34,44 +27,20 @@ const Page = async (props) => {
|
|||||||
NOTION_AUTH_URL &&
|
NOTION_AUTH_URL &&
|
||||||
NOTION_REDIRECT_URI
|
NOTION_REDIRECT_URI
|
||||||
);
|
);
|
||||||
const [session, surveys, notionIntegration, environment] = await Promise.all([
|
|
||||||
getServerSession(authOptions),
|
const { isReadOnly, environment } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
const [surveys, notionIntegration] = await Promise.all([
|
||||||
getSurveys(params.environmentId),
|
getSurveys(params.environmentId),
|
||||||
getIntegrationByType(params.environmentId, "notion"),
|
getIntegrationByType(params.environmentId, "notion"),
|
||||||
getEnvironment(params.environmentId),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await getProjectByEnvironmentId(params.environmentId);
|
|
||||||
if (!project) {
|
|
||||||
throw new Error(t("common.project_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let databasesArray: TIntegrationNotionDatabase[] = [];
|
let databasesArray: TIntegrationNotionDatabase[] = [];
|
||||||
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
|
||||||
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
|
||||||
}
|
}
|
||||||
const locale = await findMatchingLocale();
|
const locale = await findMatchingLocale();
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(
|
|
||||||
session?.user.id,
|
|
||||||
project.organizationId
|
|
||||||
);
|
|
||||||
const { isMember } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
redirect("./");
|
redirect("./");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,71 +9,40 @@ import notionLogo from "@/images/notion.png";
|
|||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import WebhookLogo from "@/images/webhook.png";
|
import WebhookLogo from "@/images/webhook.png";
|
||||||
import ZapierLogo from "@/images/zapier-small.png";
|
import ZapierLogo from "@/images/zapier-small.png";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
|
||||||
import { Card } from "@/modules/ui/components/integration-card";
|
import { Card } from "@/modules/ui/components/integration-card";
|
||||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
import { getTranslate } from "@/tolgee/server";
|
import { getTranslate } from "@/tolgee/server";
|
||||||
import { getServerSession } from "next-auth";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
|
||||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
|
||||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
|
||||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
|
||||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
|
||||||
import { TIntegrationType } from "@formbricks/types/integration";
|
import { TIntegrationType } from "@formbricks/types/integration";
|
||||||
|
|
||||||
const Page = async (props) => {
|
const Page = async (props) => {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const environmentId = params.environmentId;
|
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
const { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
environment,
|
|
||||||
integrations,
|
integrations,
|
||||||
organization,
|
|
||||||
session,
|
|
||||||
userWebhookCount,
|
userWebhookCount,
|
||||||
zapierWebhookCount,
|
zapierWebhookCount,
|
||||||
makeWebhookCount,
|
makeWebhookCount,
|
||||||
n8nwebhookCount,
|
n8nwebhookCount,
|
||||||
activePiecesWebhookCount,
|
activePiecesWebhookCount,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getEnvironment(environmentId),
|
getIntegrations(params.environmentId),
|
||||||
getIntegrations(environmentId),
|
getWebhookCountBySource(params.environmentId, "user"),
|
||||||
getOrganizationByEnvironmentId(params.environmentId),
|
getWebhookCountBySource(params.environmentId, "zapier"),
|
||||||
getServerSession(authOptions),
|
getWebhookCountBySource(params.environmentId, "make"),
|
||||||
getWebhookCountBySource(environmentId, "user"),
|
getWebhookCountBySource(params.environmentId, "n8n"),
|
||||||
getWebhookCountBySource(environmentId, "zapier"),
|
getWebhookCountBySource(params.environmentId, "activepieces"),
|
||||||
getWebhookCountBySource(environmentId, "make"),
|
|
||||||
getWebhookCountBySource(environmentId, "n8n"),
|
|
||||||
getWebhookCountBySource(environmentId, "activepieces"),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const isIntegrationConnected = (type: TIntegrationType) =>
|
const isIntegrationConnected = (type: TIntegrationType) =>
|
||||||
integrations.some((integration) => integration.type === type);
|
integrations.some((integration) => integration.type === type);
|
||||||
if (!session) {
|
|
||||||
throw new Error(t("common.session_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!organization) {
|
|
||||||
throw new Error(t("common.organization_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!environment) {
|
|
||||||
throw new Error(t("common.environment_not_found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organization.id);
|
|
||||||
const { isMember, isBilling } = getAccessFlags(currentUserMembership?.role);
|
|
||||||
|
|
||||||
const projectPermission = await getProjectPermissionByUserId(session?.user.id, environment?.projectId);
|
|
||||||
|
|
||||||
const { hasReadAccess } = getTeamPermissionFlags(projectPermission);
|
|
||||||
|
|
||||||
const isReadOnly = isMember && hasReadAccess;
|
|
||||||
|
|
||||||
if (isBilling) {
|
if (isBilling) {
|
||||||
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
return redirect(`/environments/${params.environmentId}/settings/billing`);
|
||||||
@@ -244,7 +213,7 @@ const Page = async (props) => {
|
|||||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
||||||
docsText: t("common.docs"),
|
docsText: t("common.docs"),
|
||||||
docsNewTab: true,
|
docsNewTab: true,
|
||||||
connectHref: `/environments/${environmentId}/project/app-connection`,
|
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
||||||
connectText: t("common.connect"),
|
connectText: t("common.connect"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
label: "Javascript SDK",
|
label: "Javascript SDK",
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { getSlackChannels } from "@/lib/slack/service";
|
||||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
import { checkAuthorizationUpdated } from "@/lib/utils/action-client-middleware";
|
||||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getSlackChannels } from "@formbricks/lib/slack/service";
|
|
||||||
import { ZId } from "@formbricks/types/common";
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
|
||||||
const ZGetSlackChannelsAction = z.object({
|
const ZGetSlackChannelsAction = z.object({
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
|
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||||
|
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||||
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
import { AdditionalIntegrationSettings } from "@/modules/ui/components/additional-integration-settings";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||||
@@ -15,8 +17,6 @@ import Link from "next/link";
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { getLocalizedValue } from "@formbricks/lib/i18n/utils";
|
|
||||||
import { replaceHeadlineRecall } from "@formbricks/lib/utils/recall";
|
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import {
|
import {
|
||||||
TIntegrationSlack,
|
TIntegrationSlack,
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { cleanup, render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
|
import { ManageIntegration } from "./ManageIntegration";
|
||||||
|
|
||||||
|
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||||
|
deleteIntegrationAction: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||||
|
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||||
|
open ? (
|
||||||
|
<div data-testid="delete-dialog">
|
||||||
|
<button onClick={onDelete}>confirm</button>
|
||||||
|
<button onClick={() => setOpen(false)}>cancel</button>
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||||
|
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
environment: { id: "env1" } as TEnvironment,
|
||||||
|
setOpenAddIntegrationModal: vi.fn(),
|
||||||
|
setIsConnected: vi.fn(),
|
||||||
|
setSelectedIntegration: vi.fn(),
|
||||||
|
refreshChannels: vi.fn(),
|
||||||
|
handleSlackAuthorization: vi.fn(),
|
||||||
|
showReconnectButton: false,
|
||||||
|
locale: "en-US" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ManageIntegration (Slack)", () => {
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
test("empty state", () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [], key: { team: { name: "team name" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("link channel triggers handlers", async () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [], key: { team: { name: "team name" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/link_channel/));
|
||||||
|
expect(baseProps.refreshChannels).toHaveBeenCalled();
|
||||||
|
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||||
|
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("show reconnect button and triggers authorization", async () => {
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
showReconnectButton={true}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [], key: { team: { name: "Team" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
|
||||||
|
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("list integrations and open edit", async () => {
|
||||||
|
const item = {
|
||||||
|
surveyName: "S",
|
||||||
|
channelName: "C",
|
||||||
|
questions: "Q",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
surveyId: "s",
|
||||||
|
channelId: "c",
|
||||||
|
} as unknown as TIntegrationSlackConfigData;
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [item], key: { team: { name: "team name" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(screen.getByText("S")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("S"));
|
||||||
|
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
|
||||||
|
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration success", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [], key: { team: { name: "team name" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||||
|
const { default: toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||||
|
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delete integration error", async () => {
|
||||||
|
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||||
|
render(
|
||||||
|
<ManageIntegration
|
||||||
|
{...baseProps}
|
||||||
|
slackIntegration={
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
config: { data: [], key: { team: { name: "team name" } } },
|
||||||
|
} as unknown as TIntegrationSlack
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(/delete_integration/));
|
||||||
|
await userEvent.click(screen.getByText("confirm"));
|
||||||
|
const { default: toast } = await import("react-hot-toast");
|
||||||
|
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||||
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||||
import { useTranslate } from "@tolgee/react";
|
import { T, useTranslate } from "@tolgee/react";
|
||||||
import { T } from "@tolgee/react";
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { Trash2Icon } from "lucide-react";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { timeSince } from "@formbricks/lib/time";
|
|
||||||
import { TEnvironment } from "@formbricks/types/environment";
|
import { TEnvironment } from "@formbricks/types/environment";
|
||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
|
|||||||
const { t } = useTranslate();
|
const { t } = useTranslate();
|
||||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||||
const [isDeleting, setisDeleting] = useState(false);
|
const [isDeleting, setisDeleting] = useState(false);
|
||||||
const integrationArray = slackIntegration
|
let integrationArray: TIntegrationSlackConfigData[] = [];
|
||||||
? slackIntegration.config.data
|
if (slackIntegration?.config.data) {
|
||||||
? slackIntegration.config.data
|
integrationArray = slackIntegration.config.data;
|
||||||
: []
|
}
|
||||||
: [];
|
|
||||||
|
|
||||||
const handleDeleteIntegration = async () => {
|
const handleDeleteIntegration = async () => {
|
||||||
setisDeleting(true);
|
setisDeleting(true);
|
||||||
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
|
|||||||
{integrationArray &&
|
{integrationArray &&
|
||||||
integrationArray.map((data, index) => {
|
integrationArray.map((data, index) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={index}
|
key={`${index}-${data.surveyName}-${data.channelName}`}
|
||||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
editIntegration(index);
|
editIntegration(index);
|
||||||
}}>
|
}}>
|
||||||
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
|
|||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">
|
||||||
{timeSince(data.createdAt.toString(), locale)}
|
{timeSince(data.createdAt.toString(), locale)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
|
||||||
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
|
||||||
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
|
const res = await fetch(`${apiHost}/api/v1/integrations/slack`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -5,7 +7,8 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res.text);
|
const errorText = await res.text();
|
||||||
|
logger.error({ errorText }, "authorize: Could not fetch slack config");
|
||||||
throw new Error("Could not create response");
|
throw new Error("Could not create response");
|
||||||
}
|
}
|
||||||
const resJSON = await res.json();
|
const resJSON = await res.json();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user