Compare commits

..

70 Commits

Author SHA1 Message Date
Johannes 7c932a8583 feat: refresh analysis charts and dashboard feedback gating
Unify chart create and edit flows, update dashboard chart interactions, and add feedback-record availability checks with dedicated empty-state handling across analysis entry points.

Made-with: Cursor
2026-04-24 10:40:46 +02:00
Johannes 99d794ad53 feat: integrate hub feedback records into unify workspace
Add hub-backed feedback record actions and UI flows under Unify so workspaces can list and manage feedback records from a dedicated drawer and table experience.

Made-with: Cursor
2026-04-24 10:39:38 +02:00
Johannes b123965d93 feat: wire workspace settings to feedback record directories
Integrate feedback record directory selection into workspace settings and creation flows while updating workspace navigation components to expose the new workspace-level destinations.

Made-with: Cursor
2026-04-24 10:38:32 +02:00
Johannes aed47b94a8 feat: refactor feedback sources UI and routing
Rework the feedback sources flow with new source form helpers, question selection components, and a canonical feedback-sources route while retiring the legacy survey selector.

Made-with: Cursor
2026-04-24 10:37:26 +02:00
Johannes ecaa2887b7 refactor: align connector enum with formbricks_survey
Rename connector type usage from formbricks to formbricks_survey across Prisma schema, shared types, and connector service logic to keep enum contracts consistent.

Made-with: Cursor
2026-04-24 10:36:16 +02:00
Johannes ad094b2d4c fix setup + tweak chart & dashboards creation UX 2026-04-22 09:04:42 +02:00
Dhruwang Jariwala b099219244 feat: connect charts with FeedbackRecordDirectories (ENG-665) (#7760) 2026-04-20 16:46:36 +05:30
Dhruwang af82329c4a fix: add padding to FrdPicker component for improved UI 2026-04-17 14:24:23 +05:30
Dhruwang cfff6b1495 feat: connect charts with FeedbackRecordDirectories (ENG-665)
Scope every chart to a specific FeedbackRecordDirectory so Cube.js
queries are filtered by tenant_id. This ensures data isolation when
multiple FRDs exist in a workspace.

- Add feedbackRecordDirectoryId FK to Chart model + migration
- Add tenantId dimension to FeedbackRecords Cube schema
- Inject tenant filter server-side before every Cube.js query execution
- Require FRD selection when creating charts (FrdPicker component)
- Show FRD as immutable field when editing charts
- Pass feedbackRecordDirectoryId through all chart creation flows
- Update tests and add injectTenantFilter test coverage
- Add i18n keys for FRD selection UI to all locale files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 12:52:22 +05:30
Dhruwang 9bc4e69821 Merge remote-tracking branch 'origin/feat/connect-frd-with-connectors' into epic/dashboards
# Conflicts:
#	apps/web/app/(app)/workspaces/[workspaceId]/components/MainNavigation.tsx
#	apps/web/locales/de-DE.json
#	apps/web/locales/nl-NL.json
#	apps/web/locales/ro-RO.json
#	apps/web/locales/ru-RU.json
#	packages/database/schema.prisma
2026-04-17 12:28:10 +05:30
pandeymangg 81b1c036f6 fixes 2026-04-17 10:50:20 +05:30
pandeymangg 635200db78 fix e2e tests 2026-04-16 17:30:57 +05:30
pandeymangg 793320746e fix build 2026-04-16 16:31:15 +05:30
Dhruwang Jariwala 6501041a48 chore: merge epic/v5 into epic/dashboards (#7750) 2026-04-16 16:22:05 +05:30
pandeymangg f5337e77f3 fixes tests 2026-04-16 16:20:55 +05:30
pandeymangg 0192c1ed00 fixes tests 2026-04-16 16:05:31 +05:30
Dhruwang 2e2b13c36b fix: add CUBEJS and OPENAI env vars to turbo.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 16:01:08 +05:30
Dhruwang 95dd4404d1 Merge remote-tracking branch 'origin/epic/v5' into epic/dashboards 2026-04-16 15:54:05 +05:30
pandeymangg 8a9912f839 fixes feedback 2026-04-16 15:40:02 +05:30
Dhruwang Jariwala 016dc3d92a feat: Dashboards & Charts (#7390)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-16 12:51:08 +04:00
pandeymangg 3a147a2b09 fixes 2026-04-16 11:14:26 +05:30
pandeymangg e159b45911 fixes translations 2026-04-14 18:02:01 +05:30
pandeymangg 96d14b98f0 build fixes 2026-04-14 17:55:44 +05:30
pandeymangg aa90d9fd1a chore: merge with epic/v5 2026-04-14 17:17:14 +05:30
pandeymangg 3da7129413 fixes tests 2026-04-14 17:09:13 +05:30
pandeymangg 75fbb23190 chore: merge with main 2026-04-14 17:01:17 +05:30
pandeymangg 2ffe79ffd2 chore: merge with epic/v5 2026-04-14 13:23:04 +05:30
Dhruwang Jariwala 60f6ca9463 chore: deprecate environments (#7693)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-10 09:13:47 +04:00
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Dhruwang Jariwala a771ae189a refactor: rename Project to Workspace across entire codebase (#7620) 2026-03-31 17:01:17 +05:30
Anshuman Pandey 029e069af6 feat: feedback record directories (#7592) 2026-03-27 04:18:20 -07:00
Matti Nannt 81272b96e1 feat: port hub xm-suite config to epic/v5 (#7578) 2026-03-25 11:04:42 +00:00
Anshuman Pandey cffeb0513e feat: feedback records table (#7422)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-09 09:11:18 +01:00
Dhruwang Jariwala bc334c24cf feat: add Formbricks AI toggle to organization settings (#7399)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-03-05 06:56:57 -08:00
Anshuman Pandey 077a9934ad feat: csv connector (#7361)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-05 13:57:14 +01:00
Tiago 1ed8d8076e chore: remove unused postgres-init-dev volume mount from dev compose (#7412) 2026-03-04 17:34:51 +01:00
Dhruwang Jariwala 345b282733 chore: sync epic dashboards with main (#7398) 2026-03-02 13:48:04 +05:30
Dhruwang c7c30a9d58 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into sync/epic-dashboards-with-main 2026-03-02 12:48:41 +05:30
Dhruwang 08510659de chore: merge main into sync branch to update epic/dashboards 2026-03-02 12:45:58 +05:30
Dhruwang Jariwala f8fa29d56e feat: charts ui (#7332)
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 12:21:48 +00:00
Anshuman Pandey 8b048c3105 fix: polish formbricks connector (#7348)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 13:08:26 +01:00
Anshuman Pandey b2705a4f8f feat: use @formbricks/hub SDK instead of custom hub-client (#7357) 2026-02-26 13:22:52 +04:00
pandeymangg e867caa373 adds tests for hub-client 2026-02-26 14:34:16 +05:30
Theodór Tómas ff6176df0a chore: sync epic/dashboards with main (#7368) 2026-02-26 15:24:39 +07:00
TheodorTomas d0f4228b45 chore: resolve merge conflicts syncing main into epic/dashboards
- segments.ts: take main's sequential WHERE clause building to prevent pool saturation
- package.json: update zod to 3.25.76 from main
- pnpm-lock.yaml: resolve zod version references, keep @suspensive/react from epic
2026-02-26 13:46:39 +07:00
Tiago Farto de79b58648 chore: fix lock file 2026-02-25 17:28:11 +00:00
Tiago Farto 04d528b9b8 chore: fix lock file 2026-02-25 17:22:56 +00:00
Tiago Farto c815b11015 chore: refactored hub client 2026-02-25 17:19:53 +00:00
Tiago Farto 1e7830d850 chore: refactored hub initialization 2026-02-25 13:38:19 +00:00
Tiago Farto 77cd1e9bd1 chore: additional test coverage 2026-02-25 12:46:05 +00:00
Tiago Farto e665227437 feat(connector): use @formbricks/hub SDK instead of custom hub-client
- Remove apps/web/lib/connector/hub-client.ts
- Add @formbricks/hub dependency to apps/web
- pipeline-handler: create Hub client from env (HUB_API_KEY, HUB_API_URL), call SDK create in batch with same results shape
- transform: use FormbricksHub.FeedbackRecordCreateParams from SDK directly

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:20:46 +00:00
Dhruwang Jariwala 3a802810e3 feat: Charts list page with demo create/edit and real delete/duplicate (#7353)
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-25 11:20:34 +00:00
Dhruwang Jariwala fbbf917093 chore: sync dashboard epic (#7351)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Theodór Tómas <theodortomas@gmail.com>
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <72809645+jobenjada@users.noreply.github.com>
Co-authored-by: Gulshan <gulshanbahadur002@gmail.com>
2026-02-25 13:24:51 +05:30
Dhruwang a7e42bfd29 Merge main into epic/dashboards to sync with latest changes 2026-02-25 12:11:43 +05:30
Dhruwang 562fdec899 Merge branch 'epic/dashboards' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-25 12:08:10 +05:30
Harsh Bhat 75e71e39bc feat: Unify POC hackathon (#7169)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-24 17:59:28 +01:00
Tiago 337aedf463 feat: Add Formbricks Hub to Docker Compose and Helm chart (#7284) 2026-02-23 14:35:33 +01:00
Theodór Tómas d670d5de31 feat: (dashboards) listing page (#7330) 2026-02-23 20:26:03 +07:00
Theodór Tómas 5ccb4af249 feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-23 11:44:26 +05:30
Theodór Tómas 62aa186a81 chore: merge main into dashboard epic (#7321)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Bhagya Amarasinghe <b.sithumini@yahoo.com>
Co-authored-by: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Chowdhury Tafsir Ahmed Siddiki <ctafsiras@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: neila <40727091+neila@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 12:22:54 +00:00
Dhruwang cb094761ca Merge branch 'main' of https://github.com/formbricks/formbricks into epic/dashboards 2026-02-20 17:38:35 +05:30
Theodór Tómas f35e54f21d feat: (dashboards) adding analysis tab to sidebar along with placeholder pages (#7311) 2026-02-20 09:58:26 +05:30
Theodór Tómas f49f40610b feat: add Cube.js dev setup and analytics client (#7287) 2026-02-18 21:10:52 +07:00
Theodór Tómas 9e754bad9c feat: add Chart, Dashboard, DashboardWidget schema and migration (#7286) 2026-02-18 21:10:36 +07:00
Dhruwang 4dcf6fda40 fix: code rabbit feedback 2026-02-18 18:44:24 +05:30
Dhruwang 1b8ccd7199 feat: add JSON type definitions for Chart and Dashboard fields
Add Zod schemas and TypeScript types for ChartQuery, ChartConfig,
WidgetLayout. ChartQuery mirrors Cube.js REST API query format.
Register types with prisma-json-types-generator.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:17:44 +05:30
Dhruwang 4f9088559f feat: add Cube.js dev setup and analytics client
- Add Cube container to docker-compose.dev.yml (pinned v1.3.21)
- Add Cube server config (cube/cube.js) and FeedbackRecords schema
- Add @cubejs-client/core dependency and singleton client in EE module
- Add CUBEJS_API_URL and CUBEJS_API_TOKEN to .env.example

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 18:05:47 +05:30
Dhruwang 18550f1d11 feat: link Chart and Dashboard createdBy to User
- Add creator relation on Chart and Dashboard to User
- Add createdBy foreign key constraints in migration (ON DELETE SET NULL)
- Mirror Survey pattern for createdBy user tracking

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:26:42 +05:30
Dhruwang 881cd31f74 feat: add Chart, Dashboard, DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- ChartType: area, bar, line, pie, big_number only
- Remove DashboardStatus and WidgetType (widgets are always charts)
- DashboardWidget requires chartId, remove content/type fields

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:24:48 +05:30
Dhruwang e00405dca2 feat: add Chart, Dashboard, and DashboardWidget schema and migration
- Add Prisma models for Chart, Dashboard, DashboardWidget
- Add ChartType, DashboardStatus, WidgetType enums
- Add migration 20260128111722 for charts and dashboards tables

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-18 17:21:34 +05:30
1585 changed files with 72338 additions and 61936 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
+27
View File
@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################
@@ -269,5 +278,23 @@ REDIS_URL=redis://localhost:6379
# AUDIT_LOG_GET_USER_IP=0
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
# CUBEJS_API_SECRET=
# URL where the Cube.js instance is running
# CUBEJS_API_URL=http://localhost:4000
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
# CUBEJS_API_TOKEN=
#
# Cube connects to the Hub DB. With docker-compose.dev.yml defaults, use the local postgres service.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (external Hub/Postgres on the hub network): formbricks_hub_postgres, db: hub, user/pass: formbricks/formbricks_dev
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+4 -4
View File
@@ -20,12 +20,12 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- name: Cache Build
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
uses: actions/cache@v3
id: cache-build
env:
cache-name: prod-build
@@ -43,7 +43,7 @@ runs:
shell: bash
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@v3
with:
node-version: 20.x
if: steps.cache-build.outputs.cache-hit != 'true'
@@ -53,7 +53,7 @@ runs:
if: steps.cache-build.outputs.cache-hit != 'true'
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
if: steps.cache-build.outputs.cache-hit != 'true'
shell: bash
@@ -4,7 +4,7 @@ runs:
using: "composite"
steps:
- name: Checkout repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@v3
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 2
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
+48 -37
View File
@@ -57,7 +57,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 22.x
@@ -65,7 +65,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
shell: bash
- name: create .env
@@ -85,48 +85,65 @@ jobs:
echo "S3_REGION=us-east-1" >> .env
echo "S3_BUCKET_NAME=formbricks-e2e" >> .env
echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env
echo "S3_ACCESS_KEY=devrustfs-service" >> .env
echo "S3_SECRET_KEY=devrustfs-service123" >> .env
echo "S3_ACCESS_KEY=devminio" >> .env
echo "S3_SECRET_KEY=devminio123" >> .env
echo "S3_FORCE_PATH_STYLE=1" >> .env
shell: bash
- name: Start RustFS Server
- name: Install MinIO client (mc)
run: |
set -euo pipefail
MC_VERSION="RELEASE.2025-08-13T08-35-41Z"
MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive"
MC_BIN="mc.${MC_VERSION}"
MC_SUM="${MC_BIN}.sha256sum"
curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}"
curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}"
sha256sum -c "${MC_SUM}"
chmod +x "${MC_BIN}"
sudo mv "${MC_BIN}" /usr/local/bin/mc
- name: Start MinIO Server
run: |
set -euo pipefail
# Start RustFS server in background
# Start MinIO server in background
docker run -d \
--name rustfs-server \
--name minio-server \
-p 9000:9000 \
-p 9001:9001 \
-e RUSTFS_ACCESS_KEY=devrustfs \
-e RUSTFS_SECRET_KEY=devrustfs123 \
-e RUSTFS_ADDRESS=:9000 \
-e RUSTFS_CONSOLE_ENABLE=true \
-e RUSTFS_CONSOLE_ADDRESS=:9001 \
rustfs/rustfs:1.0.0-alpha.93 \
/data
-e MINIO_ROOT_USER=devminio \
-e MINIO_ROOT_PASSWORD=devminio123 \
minio/minio:RELEASE.2025-09-07T16-13-09Z \
server /data --console-address :9001
echo "RustFS server started"
echo "MinIO server started"
- name: Bootstrap RustFS bucket and browser upload CORS
- name: Wait for MinIO and create S3 bucket
run: |
set -euo pipefail
docker run --rm \
--network host \
--entrypoint /bin/sh \
-e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \
-e RUSTFS_ADMIN_USER=devrustfs \
-e RUSTFS_ADMIN_PASSWORD=devrustfs123 \
-e RUSTFS_SERVICE_USER=devrustfs-service \
-e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \
-e RUSTFS_BUCKET_NAME=formbricks-e2e \
-e RUSTFS_POLICY_NAME=formbricks-e2e-policy \
-e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \
-v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \
/tmp/rustfs-init.sh
echo "Waiting for MinIO to be ready..."
ready=0
for i in {1..60}; do
if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then
echo "MinIO is up after ${i} seconds"
ready=1
break
fi
sleep 1
done
if [ "$ready" -ne 1 ]; then
echo "::error::MinIO did not become ready within 60 seconds"
exit 1
fi
mc alias set local http://localhost:9000 devminio devminio123
mc mb --ignore-existing local/formbricks-e2e
- name: Build App
run: |
@@ -225,14 +242,8 @@ jobs:
if: failure()
with:
name: app-logs
if-no-files-found: ignore
path: app.log
- name: Output App Logs
if: failure()
run: |
if [ -f app.log ]; then
cat app.log
else
echo "app.log not found because the Run App step did not execute or failed before log creation."
fi
run: cat app.log
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 20.x
@@ -29,7 +29,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -33,7 +33,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -2
View File
@@ -22,7 +22,7 @@ jobs:
- uses: ./.github/actions/dangerous-git-checkout
- name: Setup Node.js 20.x
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2
with:
node-version: 20.x
@@ -30,7 +30,7 @@ jobs:
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: create .env
run: cp .env.example .env
+2 -3
View File
@@ -2,7 +2,6 @@ name: Translation Validation
permissions:
contents: read
pull-requests: read
on:
pull_request:
@@ -40,7 +39,7 @@ jobs:
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
@@ -50,7 +49,7 @@ jobs:
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --frozen-lockfile --config.platform=linux --config.architecture=x64
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
+1
View File
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
We are using SonarQube to identify code smells and security hotspots.
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
## Architecture & Patterns
+12 -12
View File
@@ -11,19 +11,19 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"devDependencies": {
"@chromatic-com/storybook": "5.0.2",
"@storybook/addon-a11y": "10.3.5",
"@storybook/addon-docs": "10.3.5",
"@storybook/addon-links": "10.3.5",
"@storybook/addon-onboarding": "10.3.5",
"@storybook/react-vite": "10.3.5",
"@tailwindcss/vite": "4.2.4",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.17",
"@storybook/addon-links": "10.2.17",
"@storybook/addon-onboarding": "10.2.17",
"@storybook/react-vite": "10.2.17",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.57.0",
"@vitejs/plugin-react": "5.1.4",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.3.5",
"storybook": "10.3.5",
"vite": "7.3.2"
"eslint-plugin-storybook": "10.2.17",
"storybook": "10.2.17",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.17"
}
}
@@ -1,4 +1,4 @@
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
@@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => {
test("throws DatabaseError on Prisma error", async () => {
vi.mocked(prisma.team.findMany).mockRejectedValueOnce(
new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" })
);
await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError);
});
@@ -1,6 +1,6 @@
"use server";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
@@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache(
name: team.name,
}));
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
@@ -44,7 +44,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
<Image src={FBLogo} width={160} height={30} alt={t("workspace.formbricks_logo")} />
<div className="flex items-center">
<DropdownMenu>
@@ -105,7 +105,6 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
organizationId: organization.id,
redirect: true,
callbackUrl: "/auth/login",
clearEnvironmentId: true,
});
}}
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
@@ -1,8 +1,7 @@
import { getServerSession } from "next-auth";
import { notFound, redirect } from "next/navigation";
import { getEnvironments } from "@/lib/environment/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
const LandingLayout = async (props: {
@@ -24,16 +23,11 @@ const LandingLayout = async (props: {
return notFound();
}
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
if (projects.length !== 0) {
const firstProject = projects[0];
const environments = await getEnvironments(firstProject.id);
const prodEnvironment = environments.find((e) => e.type === "production");
if (prodEnvironment) {
return redirect(`/environments/${prodEnvironment.id}/`);
}
if (workspaces.length !== 0) {
const firstWorkspace = workspaces[0];
return redirect(`/workspaces/${firstWorkspace.id}/`);
}
return <>{children}</>;
@@ -1,6 +1,6 @@
import { notFound, redirect } from "next/navigation";
import { LandingSidebar } from "@/app/(app)/(onboarding)/organizations/[organizationId]/landing/components/landing-sidebar";
import { ProjectAndOrgSwitch } from "@/app/(app)/environments/[environmentId]/components/project-and-org-switch";
import { WorkspaceAndOrgSwitch } from "@/app/(app)/workspaces/[workspaceId]/components/workspace-and-org-switch";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
@@ -35,12 +35,12 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
<div className="flex-1">
<div className="flex h-full flex-col">
<div className="p-6">
{/* we only need to render organization breadcrumb on this page, organizations/projects are lazy-loaded */}
<ProjectAndOrgSwitch
{/* we only need to render organization breadcrumb on this page, organizations/workspaces are lazy-loaded */}
<WorkspaceAndOrgSwitch
currentOrganizationId={organization.id}
currentOrganizationName={organization.name}
isMultiOrgEnabled={isMultiOrgEnabled}
organizationProjectsLimit={0}
organizationWorkspacesLimit={0}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isLicenseActive={false}
isOwnerOrManager={false}
@@ -48,7 +48,6 @@ const Page = async (props: { params: Promise<{ organizationId: string }> }) => {
isMember={isMember}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
environments={[]}
/>
</div>
<div className="flex h-full flex-col items-center justify-center space-y-12">
@@ -8,7 +8,7 @@ import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ToasterClient } from "@/modules/ui/components/toaster-client";
const ProjectOnboardingLayout = async (props: {
const WorkspaceOnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
children: React.ReactNode;
}) => {
@@ -47,4 +47,4 @@ const ProjectOnboardingLayout = async (props: {
);
};
export default ProjectOnboardingLayout;
export default WorkspaceOnboardingLayout;
@@ -2,7 +2,7 @@ import { PictureInPicture2Icon, SendIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,7 +39,7 @@ const Page = async (props: ChannelPageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
@@ -48,7 +48,7 @@ const Page = async (props: ChannelPageProps) => {
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
/>
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,10 +4,10 @@ import { ResourceNotFoundError } from "@formbricks/types/errors";
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 { getOrganizationWorkspacesCount } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationWorkspacesLimit } from "@/modules/ee/license-check/lib/utils";
const OnboardingLayout = async (props: {
params: Promise<{ organizationId: string }>;
@@ -32,12 +32,12 @@ const OnboardingLayout = async (props: {
throw new ResourceNotFoundError(t("common.organization"), params.organizationId);
}
const [organizationProjectsLimit, organizationProjectsCount] = await Promise.all([
getOrganizationProjectsLimit(organization.id),
getOrganizationProjectsCount(organization.id),
const [organizationWorkspacesLimit, organizationWorkspacesCount] = await Promise.all([
getOrganizationWorkspacesLimit(organization.id),
getOrganizationWorkspacesCount(organization.id),
]);
if (organizationProjectsCount >= organizationProjectsLimit) {
if (organizationWorkspacesCount >= organizationWorkspacesLimit) {
return redirect(`/`);
}
@@ -2,7 +2,7 @@ import { HeartIcon, ListTodoIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
@@ -39,13 +39,13 @@ const Page = async (props: ModePageProps) => {
},
];
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -13,8 +13,8 @@ export const SelectPlanOnboarding = async ({ organizationId }: SelectPlanOnboard
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
<Header
title={t("environments.settings.billing.select_plan_header_title")}
subtitle={t("environments.settings.billing.select_plan_header_subtitle")}
title={t("workspace.settings.billing.select_plan_header_title")}
subtitle={t("workspace.settings.billing.select_plan_header_subtitle")}
/>
<SelectPlanCard nextUrl={nextUrl} organizationId={organizationId} />
</div>
@@ -8,19 +8,19 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import {
TProjectConfigChannel,
TProjectConfigIndustry,
TProjectMode,
TProjectUpdateInput,
ZProjectUpdateInput,
} from "@formbricks/types/project";
import { createProjectAction } from "@/app/(app)/environments/[environmentId]/actions";
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
TWorkspaceUpdateInput,
ZWorkspaceUpdateInput,
} from "@formbricks/types/workspace";
import { createWorkspaceAction } from "@/app/(app)/workspaces/[workspaceId]/actions";
import { previewSurvey } from "@/app/lib/templates";
import { FORMBRICKS_SURVEYS_FILTERS_KEY_LS } from "@/lib/localStorage";
import { buildStylingFromBrandColor } from "@/lib/styling/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { TOrganizationTeam } from "@/modules/ee/teams/project-teams/types/team";
import { CreateTeamModal } from "@/modules/ee/teams/team-list/components/create-team-modal";
import { TOrganizationTeam } from "@/modules/ee/teams/workspace-teams/types/team";
import { Button } from "@/modules/ui/components/button";
import { ColorPicker } from "@/modules/ui/components/color-picker";
import {
@@ -36,34 +36,34 @@ import { Input } from "@/modules/ui/components/input";
import { MultiSelect } from "@/modules/ui/components/multi-select";
import { SurveyInline } from "@/modules/ui/components/survey";
interface ProjectSettingsProps {
interface WorkspaceSettingsProps {
organizationId: string;
projectMode: TProjectMode;
channel: TProjectConfigChannel;
industry: TProjectConfigIndustry;
workspaceMode: TWorkspaceMode;
channel: TWorkspaceConfigChannel;
industry: TWorkspaceConfigIndustry;
defaultBrandColor: string;
organizationTeams: TOrganizationTeam[];
isAccessControlAllowed: boolean;
userProjectsCount: number;
userWorkspacesCount: number;
publicDomain: string;
}
export const ProjectSettings = ({
export const WorkspaceSettings = ({
organizationId,
projectMode,
workspaceMode,
channel,
industry,
defaultBrandColor,
organizationTeams,
isAccessControlAllowed = false,
userProjectsCount,
userWorkspacesCount,
publicDomain,
}: ProjectSettingsProps) => {
}: WorkspaceSettingsProps) => {
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
const router = useRouter();
const { t } = useTranslation();
const addProject = async (data: TProjectUpdateInput) => {
const addWorkspace = async (data: TWorkspaceUpdateInput) => {
try {
// Build the full styling from the chosen brand color so all derived
// colours (question, button, input, option, progress, etc.) are persisted.
@@ -71,7 +71,7 @@ export const ProjectSettings = ({
// back to STYLE_DEFAULTS computed from the default brand (#64748b).
const fullStyling = buildStylingFromBrandColor(data.styling?.brandColor?.light);
const createProjectResponse = await createProjectAction({
const createWorkspaceResponse = await createWorkspaceAction({
organizationId,
data: {
...data,
@@ -81,26 +81,21 @@ export const ProjectSettings = ({
},
});
if (createProjectResponse?.data) {
// get production environment
const productionEnvironment = createProjectResponse.data.environments.find(
(environment) => environment.type === "production"
);
if (productionEnvironment) {
if (globalThis.window !== undefined) {
// Rmove filters when creating a new project
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
if (createWorkspaceResponse?.data) {
if (globalThis.window !== undefined) {
// Remove filters when creating a new workspace
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
}
const workspaceId = createWorkspaceResponse.data.id;
if (channel === "app" || channel === "website") {
router.push(`/environments/${productionEnvironment?.id}/connect`);
router.push(`/workspaces/${workspaceId}/connect`);
} else if (channel === "link") {
router.push(`/environments/${productionEnvironment?.id}/surveys`);
} else if (projectMode === "cx") {
router.push(`/environments/${productionEnvironment?.id}/xm-templates`);
router.push(`/workspaces/${workspaceId}/surveys`);
} else if (workspaceMode === "cx") {
router.push(`/workspaces/${workspaceId}/xm-templates`);
}
} else {
const errorMessage = getFormattedErrorMessage(createProjectResponse);
const errorMessage = getFormattedErrorMessage(createWorkspaceResponse);
toast.error(errorMessage);
}
} catch (error) {
@@ -109,15 +104,15 @@ export const ProjectSettings = ({
}
};
const form = useForm<TProjectUpdateInput>({
const form = useForm<TWorkspaceUpdateInput>({
defaultValues: {
name: "",
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
teamIds: [],
},
resolver: zodResolver(ZProjectUpdateInput),
resolver: zodResolver(ZWorkspaceUpdateInput),
});
const projectName = form.watch("name");
const workspaceName = form.watch("name");
const logoUrl = form.watch("logo.url");
const brandColor = form.watch("styling.brandColor.light") ?? defaultBrandColor;
const previewStyling = useMemo(() => buildStylingFromBrandColor(brandColor), [brandColor]);
@@ -132,7 +127,7 @@ export const ProjectSettings = ({
<div className="mt-6 flex w-5/6 space-x-10 lg:w-2/3 2xl:w-1/2">
<div className="flex w-1/2 flex-col space-y-4">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(addProject)} className="w-full space-y-4">
<form onSubmit={form.handleSubmit(addWorkspace)} className="w-full space-y-4">
<FormField
control={form.control}
name="styling.brandColor.light"
@@ -184,7 +179,7 @@ export const ProjectSettings = ({
)}
/>
{isAccessControlAllowed && userProjectsCount > 0 && (
{isAccessControlAllowed && userWorkspacesCount > 0 && (
<FormField
control={form.control}
name="teamIds"
@@ -242,7 +237,7 @@ export const ProjectSettings = ({
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
survey={previewSurvey(workspaceName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
@@ -2,30 +2,34 @@ import { XIcon } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
import {
TWorkspaceConfigChannel,
TWorkspaceConfigIndustry,
TWorkspaceMode,
} from "@formbricks/types/workspace";
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
import { WorkspaceSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/WorkspaceSettings";
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getUserProjects } from "@/lib/project/service";
import { getUserWorkspaces } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ProjectSettingsPageProps {
interface WorkspaceSettingsPageProps {
params: Promise<{
organizationId: string;
}>;
searchParams: Promise<{
channel?: TProjectConfigChannel;
industry?: TProjectConfigIndustry;
mode?: TProjectMode;
channel?: TWorkspaceConfigChannel;
industry?: TWorkspaceConfigIndustry;
mode?: TWorkspaceMode;
}>;
}
const Page = async (props: ProjectSettingsPageProps) => {
const Page = async (props: WorkspaceSettingsPageProps) => {
const searchParams = await props.searchParams;
const params = await props.params;
const t = await getTranslate();
@@ -39,7 +43,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
const channel = searchParams.channel ?? null;
const industry = searchParams.industry ?? null;
const mode = searchParams.mode ?? "surveys";
const projects = await getUserProjects(session.user.id, params.organizationId);
const workspaces = await getUserWorkspaces(session.user.id, params.organizationId);
const organizationTeams = await getTeamsByOrganizationId(params.organizationId);
@@ -57,18 +61,18 @@ const Page = async (props: ProjectSettingsPageProps) => {
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
/>
<ProjectSettings
<WorkspaceSettings
organizationId={params.organizationId}
projectMode={mode}
workspaceMode={mode}
channel={channel}
industry={industry}
defaultBrandColor={DEFAULT_BRAND_COLOR}
organizationTeams={organizationTeams}
isAccessControlAllowed={isAccessControlAllowed}
userProjectsCount={projects.length}
userWorkspacesCount={workspaces.length}
publicDomain={publicDomain}
/>
{projects.length >= 1 && (
{workspaces.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
@@ -4,21 +4,20 @@ import { ArrowRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { cn } from "@/lib/cn";
import { Button } from "@/modules/ui/components/button";
import { OnboardingSetupInstructions } from "./OnboardingSetupInstructions";
interface ConnectWithFormbricksProps {
environment: TEnvironment;
workspaceId: string;
publicDomain: string;
appSetupCompleted: boolean;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
}
export const ConnectWithFormbricks = ({
environment,
workspaceId,
publicDomain,
appSetupCompleted,
channel,
@@ -26,7 +25,7 @@ export const ConnectWithFormbricks = ({
const { t } = useTranslation();
const router = useRouter();
const handleFinishOnboarding = async () => {
router.push(`/environments/${environment.id}/surveys`);
router.push(`/workspaces/${workspaceId}/surveys`);
};
useEffect(() => {
@@ -48,7 +47,7 @@ export const ConnectWithFormbricks = ({
<div className="flex w-full space-x-10">
<div className="flex w-1/2 flex-col space-y-4">
<OnboardingSetupInstructions
environmentId={environment.id}
workspaceId={workspaceId}
publicDomain={publicDomain}
channel={channel}
appSetupCompleted={appSetupCompleted}
@@ -61,9 +60,9 @@ export const ConnectWithFormbricks = ({
)}>
{appSetupCompleted ? (
<div>
<p className="text-3xl">{t("environments.connect.congrats")}</p>
<p className="text-3xl">{t("workspace.connect.congrats")}</p>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.connection_successful_message")}
{t("workspace.connect.connection_successful_message")}
</p>
</div>
) : (
@@ -73,7 +72,7 @@ export const ConnectWithFormbricks = ({
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
{t("environments.connect.waiting_for_your_signal")}
{t("workspace.connect.waiting_for_your_signal")}
</p>
</div>
)}
@@ -83,9 +82,7 @@ export const ConnectWithFormbricks = ({
id="finishOnboarding"
variant={appSetupCompleted ? "default" : "ghost"}
onClick={handleFinishOnboarding}>
{appSetupCompleted
? t("environments.connect.finish_onboarding")
: t("environments.connect.do_it_later")}
{appSetupCompleted ? t("workspace.connect.finish_onboarding") : t("workspace.connect.do_it_later")}
<ArrowRight />
</Button>
</div>
@@ -5,7 +5,7 @@ import "prismjs/themes/prism.css";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProjectConfigChannel } from "@formbricks/types/project";
import { TWorkspaceConfigChannel } from "@formbricks/types/workspace";
import { Button } from "@/modules/ui/components/button";
import { CodeBlock } from "@/modules/ui/components/code-block";
import { Html5Icon, NpmIcon } from "@/modules/ui/components/icons";
@@ -17,14 +17,14 @@ const tabs = [
];
interface OnboardingSetupInstructionsProps {
environmentId: string;
workspaceId: string;
publicDomain: string;
channel: TProjectConfigChannel;
channel: TWorkspaceConfigChannel;
appSetupCompleted: boolean;
}
export const OnboardingSetupInstructions = ({
environmentId,
workspaceId,
publicDomain,
channel,
appSetupCompleted,
@@ -35,8 +35,8 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
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 workspaceId = "${workspaceId}";
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({workspaceId:workspaceId,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>
<!-- END Formbricks Surveys -->
`;
@@ -45,46 +45,46 @@ export const OnboardingSetupInstructions = ({
<script type="text/javascript">
!function(){
var appUrl = "${publicDomain}";
var environmentId = "${environmentId}";
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 workspaceId = "${workspaceId}";
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({workspaceId:workspaceId,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>
<!-- END Formbricks Surveys -->
`;
const npmSnippetForAppSurveys = `
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
const npmSnippetForWebsiteSurveys = `
// other imports
import formbricks from "@formbricks/js";
if (typeof window !== "undefined") {
formbricks.setup({
environmentId: "${environmentId}",
workspaceId: "${workspaceId}",
appUrl: "${publicDomain}",
});
}
function App() {
// your own app
}
export default App;
`;
return (
@@ -109,7 +109,7 @@ export const OnboardingSetupInstructions = ({
yarn add @formbricks/js
</CodeBlock>
<p className="text-sm text-slate-700">
{t("environments.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
{t("workspace.connect.import_formbricks_and_initialize_the_widget_in_your_component")}
</p>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
{channel === "app" ? npmSnippetForAppSurveys : npmSnippetForWebsiteSurveys}
@@ -126,7 +126,7 @@ export const OnboardingSetupInstructions = ({
) : activeTab === "html" ? (
<div className="prose prose-slate">
<p className="-mb-1 mt-6 text-sm text-slate-700">
{t("environments.connect.insert_this_code_into_the_head_tag_of_your_website")}
{t("workspace.connect.insert_this_code_into_the_head_tag_of_your_website")}
</p>
<div>
<CodeBlock customEditorClass="!bg-white border border-slate-200" language="js">
@@ -1,56 +1,50 @@
import { XIcon } from "lucide-react";
import Link from "next/link";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/environments/[environmentId]/connect/components/ConnectWithFormbricks";
import { getEnvironment } from "@/lib/environment/service";
import { ConnectWithFormbricks } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/connect/components/ConnectWithFormbricks";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { Button } from "@/modules/ui/components/button";
import { Header } from "@/modules/ui/components/header";
interface ConnectPageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: ConnectPageProps) => {
const params = await props.params;
const t = await getTranslate();
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const channel = project.config.channel || null;
const channel = workspace.config.channel || null;
const publicDomain = getPublicDomain();
return (
<div className="flex min-h-full flex-col items-center justify-center py-10">
<Header title={t("environments.connect.headline")} subtitle={t("environments.connect.subtitle")} />
<Header title={t("workspace.connect.headline")} subtitle={t("workspace.connect.subtitle")} />
<div className="space-y-4 text-center">
<p className="text-4xl font-medium text-slate-800"></p>
<p className="text-sm text-slate-500"></p>
</div>
<ConnectWithFormbricks
environment={environment}
workspaceId={params.workspaceId}
publicDomain={publicDomain}
appSetupCompleted={environment.appSetupCompleted}
appSetupCompleted={workspace.appSetupCompleted}
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
<Link href={`/workspaces/${params.workspaceId}`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
@@ -1,11 +1,11 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { AuthorizationError } from "@formbricks/types/errors";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { hasUserWorkspaceAccess } from "@/lib/workspace/auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
const OnboardingLayout = async (props: {
params: Promise<{ environmentId: string }>;
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
@@ -17,9 +17,9 @@ const OnboardingLayout = async (props: {
return redirect(`/auth/login`);
}
const isAuthorized = await hasUserEnvironmentAccess(session.user.id, params.environmentId);
const isAuthorized = await hasUserWorkspaceAccess(session.user.id, params.workspaceId);
if (!isAuthorized) {
throw new AuthorizationError("User is not authorized to access this environment");
throw new AuthorizationError("User is not authorized to access this workspace");
}
return <div className="flex-1 bg-slate-50">{children}</div>;
@@ -5,23 +5,23 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TProject } from "@formbricks/types/project";
import { TSurveyCreateInput } from "@formbricks/types/surveys/types";
import { TXMTemplate } from "@formbricks/types/templates";
import { TUser } from "@formbricks/types/user";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/lib/xm-templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { OnboardingOptionsContainer } from "@/app/(app)/(onboarding)/organizations/components/OnboardingOptionsContainer";
import { replacePresetPlaceholders } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/utils";
import { getXMTemplates } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/lib/xm-templates";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { createSurveyAction } from "@/modules/survey/components/template-list/actions";
interface XMTemplateListProps {
project: TProject;
workspace: TWorkspace;
user: TUser;
environmentId: string;
workspaceId: string;
}
export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListProps) => {
export const XMTemplateList = ({ workspace, user, workspaceId }: XMTemplateListProps) => {
const [activeTemplateId, setActiveTemplateId] = useState<number | null>(null);
const { t } = useTranslation();
const router = useRouter();
@@ -33,12 +33,12 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
createdBy: user.id,
};
const createSurveyResponse = await createSurveyAction({
environmentId: environmentId,
workspaceId: workspaceId,
surveyBody: augmentedTemplate,
});
if (createSurveyResponse?.data) {
router.push(`/environments/${environmentId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
router.push(`/workspaces/${workspaceId}/surveys/${createSurveyResponse.data.id}/edit?mode=cx`);
} else {
const errorMessage = getFormattedErrorMessage(createSurveyResponse);
toast.error(errorMessage);
@@ -48,49 +48,49 @@ export const XMTemplateList = ({ project, user, environmentId }: XMTemplateListP
const handleTemplateClick = (templateIdx: number) => {
setActiveTemplateId(templateIdx);
const template = getXMTemplates(t)[templateIdx];
const newTemplate = replacePresetPlaceholders(template, project);
const newTemplate = replacePresetPlaceholders(template, workspace);
createSurvey(newTemplate);
};
const XMTemplateOptions = [
{
title: t("environments.xm-templates.nps"),
description: t("environments.xm-templates.nps_description"),
title: t("workspace.xm-templates.nps"),
description: t("workspace.xm-templates.nps_description"),
icon: ShoppingCartIcon,
onClick: () => handleTemplateClick(0),
isLoading: activeTemplateId === 0,
},
{
title: t("environments.xm-templates.five_star_rating"),
description: t("environments.xm-templates.five_star_rating_description"),
title: t("workspace.xm-templates.five_star_rating"),
description: t("workspace.xm-templates.five_star_rating_description"),
icon: StarIcon,
onClick: () => handleTemplateClick(1),
isLoading: activeTemplateId === 1,
},
{
title: t("environments.xm-templates.csat"),
description: t("environments.xm-templates.csat_description"),
title: t("workspace.xm-templates.csat"),
description: t("workspace.xm-templates.csat_description"),
icon: ThumbsUpIcon,
onClick: () => handleTemplateClick(2),
isLoading: activeTemplateId === 2,
},
{
title: t("environments.xm-templates.ces"),
description: t("environments.xm-templates.ces_description"),
title: t("workspace.xm-templates.ces"),
description: t("workspace.xm-templates.ces_description"),
icon: ActivityIcon,
onClick: () => handleTemplateClick(3),
isLoading: activeTemplateId === 3,
},
{
title: t("environments.xm-templates.smileys"),
description: t("environments.xm-templates.smileys_description"),
title: t("workspace.xm-templates.smileys"),
description: t("workspace.xm-templates.smileys_description"),
icon: SmileIcon,
onClick: () => handleTemplateClick(4),
isLoading: activeTemplateId === 4,
},
{
title: t("environments.xm-templates.enps"),
description: t("environments.xm-templates.enps_description"),
title: t("workspace.xm-templates.enps"),
description: t("workspace.xm-templates.enps_description"),
icon: UsersIcon,
onClick: () => handleTemplateClick(5),
isLoading: activeTemplateId === 5,
@@ -1,17 +1,17 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { TProject } from "@formbricks/types/project";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replacePresetPlaceholders } from "./utils";
// Mock data
const mockProject: TProject = {
id: "project1",
const mockWorkspace: TWorkspace = {
id: "workspace1",
createdAt: new Date(),
updatedAt: new Date(),
name: "Test Project",
name: "Test Workspace",
organizationId: "org1",
styling: {
allowStyleOverwrite: true,
@@ -27,12 +27,12 @@ const mockProject: TProject = {
placement: "bottomRight",
clickOutsideClose: true,
overlay: "none",
environments: [],
appSetupCompleted: false,
languages: [],
logo: null,
};
const mockTemplate: TXMTemplate = {
name: "$[projectName] Survey",
name: "$[workspaceName] Survey",
blocks: [
{
id: "block1",
@@ -42,7 +42,7 @@ const mockTemplate: TXMTemplate = {
id: "q1",
type: "openText" as TSurveyElementTypeEnum.OpenText,
inputType: "text" as const,
headline: { default: "$[projectName] Question" },
headline: { default: "$[workspaceName] Question" },
subheader: { default: "" },
required: false,
placeholder: { default: "" },
@@ -70,19 +70,19 @@ describe("replacePresetPlaceholders", () => {
cleanup();
});
test("replaces projectName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.name).toBe("Test Project Survey");
test("replaces workspaceName placeholder in template name", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.name).toBe("Test Workspace Survey");
});
test("replaces projectName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockProject);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Project Question");
test("replaces workspaceName placeholder in element headline", () => {
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result.blocks[0].elements[0].headline.default).toBe("Test Workspace Question");
});
test("returns a new object without mutating the original template", () => {
const originalTemplate = structuredClone(mockTemplate);
const result = replacePresetPlaceholders(mockTemplate, mockProject);
const result = replacePresetPlaceholders(mockTemplate, mockWorkspace);
expect(result).not.toBe(mockTemplate);
expect(mockTemplate).toEqual(originalTemplate);
});
@@ -1,16 +1,16 @@
import { TProject } from "@formbricks/types/project";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TXMTemplate } from "@formbricks/types/templates";
import { TWorkspace } from "@formbricks/types/workspace";
import { replaceElementPresetPlaceholders } from "@/lib/utils/templates";
// replace all occurences of projectName with the actual project name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, project: TProject): TXMTemplate => {
// replace all occurences of workspaceName with the actual workspace name in the current template
export const replacePresetPlaceholders = (template: TXMTemplate, workspace: TWorkspace): TXMTemplate => {
const survey = structuredClone(template);
const modifiedBlocks = survey.blocks.map((block: TSurveyBlock) => ({
...block,
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, project)),
elements: block.elements.map((element) => replaceElementPresetPlaceholders(element, workspace)),
}));
return { ...survey, name: survey.name.replace("$[projectName]", project.name), blocks: modifiedBlocks };
return { ...survey, name: survey.name.replace("$[workspaceName]", workspace.name), blocks: modifiedBlocks };
};
@@ -2,11 +2,9 @@ import { XIcon } from "lucide-react";
import { getServerSession } from "next-auth";
import Link from "next/link";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { XMTemplateList } from "@/app/(app)/(onboarding)/environments/[environmentId]/xm-templates/components/XMTemplateList";
import { getEnvironment } from "@/lib/environment/service";
import { getProjectByEnvironmentId, getUserProjects } from "@/lib/project/service";
import { XMTemplateList } from "@/app/(app)/(onboarding)/workspaces/[workspaceId]/xm-templates/components/XMTemplateList";
import { getUser } from "@/lib/user/service";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getUserWorkspaces, getWorkspace } from "@/lib/workspace/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { Button } from "@/modules/ui/components/button";
@@ -14,15 +12,15 @@ import { Header } from "@/modules/ui/components/header";
interface XMTemplatePageProps {
params: Promise<{
environmentId: string;
workspaceId: string;
}>;
}
const Page = async (props: XMTemplatePageProps) => {
const params = await props.params;
const session = await getServerSession(authOptions);
const environment = await getEnvironment(params.environmentId);
const t = await getTranslate();
if (!session) {
throw new AuthenticationError(t("common.not_authenticated"));
}
@@ -31,29 +29,24 @@ const Page = async (props: XMTemplatePageProps) => {
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
const organizationId = await getOrganizationIdFromEnvironmentId(environment.id);
const project = await getProjectByEnvironmentId(environment.id);
if (!project) {
throw new ResourceNotFoundError(t("common.workspace"), null);
}
const projects = await getUserProjects(session.user.id, organizationId);
const workspaces = await getUserWorkspaces(session.user.id, workspace.organizationId);
return (
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
<Header title={t("environments.xm-templates.headline")} />
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Header title={t("workspace.xm-templates.headline")} />
<XMTemplateList workspace={workspace} user={user} workspaceId={params.workspaceId} />
{workspaces.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
<Link href={`/workspaces/${params.workspaceId}/surveys`}>
<XIcon className="h-7 w-7" strokeWidth={1.5} />
</Link>
</Button>
@@ -1,37 +0,0 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { environmentIdLayoutChecks } from "@/modules/environments/lib/utils";
const SurveyEditorEnvironmentLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await environmentIdLayoutChecks(params.environmentId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const environment = await getEnvironment(params.environmentId);
if (!environment) {
throw new ResourceNotFoundError(t("common.environment"), params.environmentId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorEnvironmentLayout;
@@ -0,0 +1,37 @@
import { redirect } from "next/navigation";
import { AuthenticationError, ResourceNotFoundError } from "@formbricks/types/errors";
import { getWorkspace } from "@/lib/workspace/service";
import { workspaceIdLayoutChecks } from "@/modules/workspaces/lib/utils";
const SurveyEditorWorkspaceLayout = async (props: {
params: Promise<{ workspaceId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
const { t, session, user } = await workspaceIdLayoutChecks(params.workspaceId);
if (!session) {
return redirect(`/auth/login`);
}
if (!user) {
throw new AuthenticationError(t("common.not_authenticated"));
}
const workspace = await getWorkspace(params.workspaceId);
if (!workspace) {
throw new ResourceNotFoundError(t("common.workspace"), params.workspaceId);
}
return (
<div className="flex h-screen flex-col">
<div className="h-full overflow-y-auto bg-slate-50">{children}</div>
</div>
);
};
export default SurveyEditorWorkspaceLayout;
@@ -6,12 +6,12 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";
const BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY = "billingConfirmationEnvironmentId";
const BILLING_CONFIRMATION_WORKSPACE_ID_KEY = "billingConfirmationWorkspaceId";
export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedEnvironmentId, setResolvedEnvironmentId] = useState<string | null>(null);
const [resolvedWorkspaceId, setResolvedWorkspaceId] = useState<string | null>(null);
useEffect(() => {
setShowConfetti(true);
@@ -20,11 +20,9 @@ export const ConfirmationPage = () => {
return;
}
const storedEnvironmentId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ENVIRONMENT_ID_KEY
);
if (storedEnvironmentId) {
setResolvedEnvironmentId(storedEnvironmentId);
const storedWorkspaceId = globalThis.window.sessionStorage.getItem(BILLING_CONFIRMATION_WORKSPACE_ID_KEY);
if (storedWorkspaceId) {
setResolvedWorkspaceId(storedWorkspaceId);
}
}, []);
@@ -41,12 +39,7 @@ export const ConfirmationPage = () => {
</p>
</div>
<Button asChild className="w-full justify-center">
<Link
href={
resolvedEnvironmentId
? `/environments/${resolvedEnvironmentId}/settings/billing`
: "/environments"
}>
<Link href={resolvedWorkspaceId ? `/workspaces/${resolvedWorkspaceId}/settings/billing` : "/"}>
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
@@ -1,4 +1,4 @@
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
import { cn } from "@/lib/cn";
export const LoadingCard = ({
@@ -1,18 +0,0 @@
"use client";
import { useEffect } from "react";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
interface EnvironmentStorageHandlerProps {
environmentId: string;
}
const EnvironmentStorageHandler = ({ environmentId }: EnvironmentStorageHandlerProps) => {
useEffect(() => {
localStorage.setItem(FORMBRICKS_ENVIRONMENT_ID_LS, environmentId);
}, [environmentId]);
return null;
};
export default EnvironmentStorageHandler;
@@ -1,56 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { TEnvironment } from "@formbricks/types/environment";
import { cn } from "@/lib/cn";
import { Label } from "@/modules/ui/components/label";
import { Switch } from "@/modules/ui/components/switch";
interface EnvironmentSwitchProps {
environment: TEnvironment;
environments: TEnvironment[];
}
export const EnvironmentSwitch = ({ environment, environments }: EnvironmentSwitchProps) => {
const { t } = useTranslation();
const router = useRouter();
const [isEnvSwitchChecked, setIsEnvSwitchChecked] = useState(environment?.type === "development");
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentType: "production" | "development") => {
const newEnvironmentId = environments.find((e) => e.type === environmentType)?.id;
if (newEnvironmentId) {
router.push(`/environments/${newEnvironmentId}/`);
}
};
const toggleEnvSwitch = () => {
const newEnvironmentType = isEnvSwitchChecked ? "production" : "development";
setIsLoading(true);
setIsEnvSwitchChecked(!isEnvSwitchChecked);
handleEnvironmentChange(newEnvironmentType);
};
return (
<div
className={cn(
"flex items-center space-x-2 rounded-lg p-2",
isEnvSwitchChecked ? "bg-slate-100 text-orange-800" : "hover:bg-slate-100"
)}>
<Label
htmlFor="development-mode"
className={cn("hover:cursor-pointer", isEnvSwitchChecked && "text-orange-800")}>
{t("common.dev_env")}
</Label>
<Switch
className="focus:ring-orange-800 data-[state=checked]:bg-orange-800"
id="development-mode"
disabled={isLoading}
checked={isEnvSwitchChecked}
onCheckedChange={toggleEnvSwitch}
/>
</div>
);
};
@@ -1,89 +0,0 @@
"use client";
import { ChevronDownIcon, CircleHelpIcon, Code2Icon, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BreadcrumbItem } from "@/modules/ui/components/breadcrumb";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
export const EnvironmentBreadcrumb = ({
environments,
currentEnvironment,
}: {
environments: { id: string; type: string }[];
currentEnvironment: { id: string; type: string };
}) => {
const { t } = useTranslation();
const [isEnvironmentDropdownOpen, setIsEnvironmentDropdownOpen] = useState(false);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const handleEnvironmentChange = (environmentId: string) => {
if (environmentId === currentEnvironment.id) return;
setIsLoading(true);
router.push(`/environments/${environmentId}/`);
};
const developmentTooltip = () => {
return (
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<CircleHelpIcon className="h-3 w-3" />
</TooltipTrigger>
<TooltipContent className="mt-2 border-none bg-red-800 text-white">
{t("common.development_environment_banner")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
return (
<BreadcrumbItem
isActive={isEnvironmentDropdownOpen}
isHighlighted={currentEnvironment.type === "development"}>
<DropdownMenu onOpenChange={setIsEnvironmentDropdownOpen}>
<DropdownMenuTrigger
className="flex cursor-pointer items-center gap-1 outline-none"
id="environmentDropdownTrigger"
asChild>
<div className="flex items-center gap-1">
<Code2Icon className="h-3 w-3" strokeWidth={1.5} />
<span className="capitalize">{currentEnvironment.type}</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
{currentEnvironment.type === "development" && developmentTooltip()}
{isEnvironmentDropdownOpen && <ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="mt-2" align="start">
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
<Code2Icon className="mr-2 inline h-4 w-4" />
{t("common.choose_environment")}
</div>
<DropdownMenuGroup>
{environments.map((env) => (
<DropdownMenuCheckboxItem
key={env.id}
checked={env.type === currentEnvironment.type}
onClick={() => handleEnvironmentChange(env.id)}
className="cursor-pointer">
<div className="flex items-center gap-2 capitalize">
<span>{env.type}</span>
</div>
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</BreadcrumbItem>
);
};
@@ -1,81 +0,0 @@
"use client";
import { EnvironmentBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/environment-breadcrumb";
import { OrganizationBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/organization-breadcrumb";
import { ProjectBreadcrumb } from "@/app/(app)/environments/[environmentId]/components/project-breadcrumb";
import { Breadcrumb, BreadcrumbList } from "@/modules/ui/components/breadcrumb";
interface ProjectAndOrgSwitchProps {
currentOrganizationId: string;
currentOrganizationName?: string; // Optional: for pages without context
currentProjectId?: string;
currentProjectName?: string; // Optional: for pages without context
currentEnvironmentId?: string;
environments: { id: string; type: string }[];
isMultiOrgEnabled: boolean;
organizationProjectsLimit: number;
isFormbricksCloud: boolean;
isLicenseActive: boolean;
isOwnerOrManager: boolean;
isMember: boolean;
isBilling: boolean;
isMembershipPending: boolean;
isAccessControlAllowed: boolean;
}
export const ProjectAndOrgSwitch = ({
currentOrganizationId,
currentOrganizationName,
currentProjectId,
currentProjectName,
currentEnvironmentId,
environments,
isMultiOrgEnabled,
organizationProjectsLimit,
isFormbricksCloud,
isLicenseActive,
isOwnerOrManager,
isAccessControlAllowed,
isMember,
isBilling,
isMembershipPending,
}: ProjectAndOrgSwitchProps) => {
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
const showEnvironmentBreadcrumb = currentEnvironment?.type === "development";
return (
<Breadcrumb>
<BreadcrumbList className="gap-0">
<OrganizationBreadcrumb
currentOrganizationId={currentOrganizationId}
currentOrganizationName={currentOrganizationName}
currentEnvironmentId={currentEnvironmentId}
isMultiOrgEnabled={isMultiOrgEnabled}
isFormbricksCloud={isFormbricksCloud}
isMember={isMember}
isOwnerOrManager={isOwnerOrManager}
isMembershipPending={isMembershipPending}
/>
{currentProjectId && currentEnvironmentId && (
<ProjectBreadcrumb
currentProjectId={currentProjectId}
currentProjectName={currentProjectName}
currentOrganizationId={currentOrganizationId}
currentEnvironmentId={currentEnvironmentId}
isOwnerOrManager={isOwnerOrManager}
organizationProjectsLimit={organizationProjectsLimit}
isFormbricksCloud={isFormbricksCloud}
isLicenseActive={isLicenseActive}
isAccessControlAllowed={isAccessControlAllowed}
isEnvironmentBreadcrumbVisible={showEnvironmentBreadcrumb}
isBilling={isBilling}
isMembershipPending={isMembershipPending}
/>
)}
{showEnvironmentBreadcrumb && (
<EnvironmentBreadcrumb environments={environments} currentEnvironment={currentEnvironment} />
)}
</BreadcrumbList>
</Breadcrumb>
);
};
@@ -1,68 +0,0 @@
"use client";
import { createContext, useContext, useMemo } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TOrganization } from "@formbricks/types/organizations";
import { TProject } from "@formbricks/types/project";
export interface EnvironmentContextType {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
organizationId: string;
}
const EnvironmentContext = createContext<EnvironmentContextType | null>(null);
export const useEnvironment = () => {
const context = useContext(EnvironmentContext);
if (!context) {
throw new Error("useEnvironment must be used within an EnvironmentProvider");
}
return context;
};
export const useProject = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { project: null };
}
return { project: context.project };
};
export const useOrganization = () => {
const context = useContext(EnvironmentContext);
if (!context) {
return { organization: null };
}
return { organization: context.organization };
};
// Client wrapper component to be used in server components
interface EnvironmentContextWrapperProps {
environment: TEnvironment;
project: TProject;
organization: TOrganization;
children: React.ReactNode;
}
export const EnvironmentContextWrapper = ({
environment,
project,
organization,
children,
}: EnvironmentContextWrapperProps) => {
const environmentContextValue = useMemo(
() => ({
environment,
project,
organization,
organizationId: project.organizationId,
}),
[environment, project, organization]
);
return (
<EnvironmentContext.Provider value={environmentContextValue}>{children}</EnvironmentContext.Provider>
);
};
@@ -1,38 +0,0 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { EnvironmentLayout } from "@/app/(app)/environments/[environmentId]/components/EnvironmentLayout";
import { EnvironmentContextWrapper } from "@/app/(app)/environments/[environmentId]/context/environment-context";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getEnvironmentLayoutData } from "@/modules/environments/lib/utils";
import EnvironmentStorageHandler from "./components/EnvironmentStorageHandler";
const EnvLayout = async (props: {
params: Promise<{ environmentId: string }>;
children: React.ReactNode;
}) => {
const params = await props.params;
const { children } = props;
// Check session first (required for userId)
const session = await getServerSession(authOptions);
if (!session?.user) {
return redirect(`/auth/login`);
}
// Single consolidated data fetch (replaces ~12 individual fetches)
const layoutData = await getEnvironmentLayoutData(params.environmentId, session.user.id);
return (
<>
<EnvironmentStorageHandler environmentId={params.environmentId} />
<EnvironmentContextWrapper
environment={layoutData.environment}
project={layoutData.project}
organization={layoutData.organization}>
<EnvironmentLayout layoutData={layoutData}>{children}</EnvironmentLayout>
</EnvironmentContextWrapper>
</>
);
};
export default EnvLayout;
@@ -1,15 +0,0 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
export const getIsEmailUnique = reactCache(async (email: string): Promise<boolean> => {
const user = await prisma.user.findUnique({
where: {
email: email.toLowerCase(),
},
select: {
id: true,
},
});
return !user;
});
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/settings/profile`);
};
export default Page;
@@ -1,59 +0,0 @@
import { describe, expect, test } from "vitest";
import { extractEmailBodyFragment } from "./emailTemplateFragment";
describe("extractEmailBodyFragment", () => {
test("returns the body contents for rendered email documents", () => {
const html = `
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body class="email-body">
<table>
<tr>
<td>Preview content</td>
</tr>
</table>
</body>
</html>
`;
expect(extractEmailBodyFragment(html)).toBe(
"<table>\n <tr>\n <td>Preview content</td>\n </tr>\n </table>"
);
});
test("removes document-level tags from rendered survey email markup", () => {
const fragment = extractEmailBodyFragment(`
<!DOCTYPE html>
<html>
<head>
<style>.foo { color: red; }</style>
</head>
<body>
<table>
<tr>
<td>Which fruits do you like</td>
</tr>
</table>
</body>
</html>
`);
expect(fragment).toBe(
"<table>\n <tr>\n <td>Which fruits do you like</td>\n </tr>\n </table>"
);
expect(fragment).not.toMatch(/<!DOCTYPE|<html|<head|<body/i);
});
test("falls back to the original markup when no body tag exists", () => {
expect(extractEmailBodyFragment("<div>Preview content</div>")).toBe("<div>Preview content</div>");
});
test("removes React server markers from rendered fragments", () => {
expect(extractEmailBodyFragment("<body><!--$--><div>Preview content</div><!--/$--></body>")).toBe(
"<div>Preview content</div>"
);
});
});
@@ -1,11 +0,0 @@
const EMAIL_DOCTYPE_PATTERN = /<!DOCTYPE[^>]*>/i;
const EMAIL_BODY_PATTERN = /<body\b[^>]*>([\s\S]*?)<\/body>/i;
const EMAIL_REACT_SERVER_MARKER_PATTERN = /<!--\/?\$-->/g;
export const extractEmailBodyFragment = (html: string): string => {
const htmlWithoutDoctype = html.replace(EMAIL_DOCTYPE_PATTERN, "").trim();
const bodyMatch = EMAIL_BODY_PATTERN.exec(htmlWithoutDoctype);
const fragment = bodyMatch?.[1].trim() ?? htmlWithoutDoctype;
return fragment.replaceAll(EMAIL_REACT_SERVER_MARKER_PATTERN, "").trim();
};
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
const Page = async (props: { params: Promise<{ environmentId: string; surveyId: string }> }) => {
const params = await props.params;
return redirect(`/environments/${params.environmentId}/surveys/${params.surveyId}/summary`);
};
export default Page;
@@ -1,8 +0,0 @@
import { type ReactNode } from "react";
import { SurveysQueryClientProvider } from "./query-client-provider";
const SurveysLayout = ({ children }: { children: ReactNode }) => {
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
};
export default SurveysLayout;
@@ -1,10 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { type ReactNode, useState } from "react";
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
const [queryClient] = useState(() => new QueryClient());
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
@@ -1,3 +0,0 @@
import { AppConnectionLoading } from "@/modules/projects/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -1,3 +0,0 @@
import { AppConnectionPage } from "@/modules/projects/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -1,3 +0,0 @@
import { GeneralSettingsLoading } from "@/modules/projects/settings/general/loading";
export default GeneralSettingsLoading;
@@ -1,3 +0,0 @@
import { GeneralSettingsPage } from "@/modules/projects/settings/general/page";
export default GeneralSettingsPage;
@@ -1,3 +0,0 @@
import { LanguagesLoading } from "@/modules/projects/settings/languages/loading";
export default LanguagesLoading;
@@ -1,3 +0,0 @@
import { LanguagesPage } from "@/modules/projects/settings/languages/page";
export default LanguagesPage;
@@ -1,4 +0,0 @@
import { ProjectSettingsLayout, metadata } from "@/modules/projects/settings/layout";
export { metadata };
export default ProjectSettingsLayout;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsLoading } from "@/modules/projects/settings/look/loading";
export default ProjectLookSettingsLoading;
@@ -1,3 +0,0 @@
import { ProjectLookSettingsPage } from "@/modules/projects/settings/look/page";
export default ProjectLookSettingsPage;
@@ -1,3 +0,0 @@
import { ProjectSettingsPage } from "@/modules/projects/settings/page";
export default ProjectSettingsPage;
@@ -1,3 +0,0 @@
import { TagsLoading } from "@/modules/projects/settings/tags/loading";
export default TagsLoading;
@@ -1,3 +0,0 @@
import { TagsPage } from "@/modules/projects/settings/tags/page";
export default TagsPage;
@@ -1,3 +0,0 @@
import { ProjectTeams } from "@/modules/ee/teams/project-teams/page";
export default ProjectTeams;
@@ -0,0 +1,8 @@
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <ChartsListPage workspaceId={workspaceId} />;
};
export default ChartsPage;
@@ -0,0 +1,7 @@
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
return <DashboardDetailPage params={props.params} />;
};
export default Page;
@@ -0,0 +1,8 @@
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
const { workspaceId } = await props.params;
return <DashboardsListPage workspaceId={workspaceId} />;
};
export default DashboardsPage;
@@ -0,0 +1,3 @@
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
export default AnalysisListLoading;
@@ -0,0 +1,3 @@
import { AppConnectionLoading } from "@/modules/workspaces/settings/(setup)/app-connection/loading";
export default AppConnectionLoading;
@@ -0,0 +1,3 @@
import { AppConnectionPage } from "@/modules/workspaces/settings/(setup)/app-connection/page";
export default AppConnectionPage;
@@ -0,0 +1 @@
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
@@ -0,0 +1,3 @@
import { GeneralSettingsLoading } from "@/modules/workspaces/settings/general/loading";
export default GeneralSettingsLoading;
@@ -0,0 +1,3 @@
import { GeneralSettingsPage } from "@/modules/workspaces/settings/general/page";
export default GeneralSettingsPage;
@@ -8,15 +8,14 @@ import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import {
getOrganizationIdFromEnvironmentId,
getOrganizationIdFromIntegrationId,
getProjectIdFromEnvironmentId,
getProjectIdFromIntegrationId,
getOrganizationIdFromWorkspaceId,
getWorkspaceIdFromIntegrationId,
} from "@/lib/utils/helper";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
const ZCreateOrUpdateIntegrationAction = z.object({
environmentId: ZId,
workspaceId: ZId,
integrationData: ZIntegrationInput,
});
@@ -24,7 +23,7 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
.inputSchema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging("createdUpdated", "integration", async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -35,15 +34,15 @@ export const createOrUpdateIntegrationAction = authenticatedActionClient
roles: ["owner", "manager"],
},
{
type: "projectTeam",
type: "workspaceTeam",
minPermission: "readWrite",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
workspaceId: parsedInput.workspaceId,
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
const result = await createOrUpdateIntegration(parsedInput.environmentId, parsedInput.integrationData);
const result = await createOrUpdateIntegration(parsedInput.workspaceId, parsedInput.integrationData);
ctx.auditLoggingCtx.integrationId = result.id;
ctx.auditLoggingCtx.newObject = result;
@@ -73,8 +72,8 @@ export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDe
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromIntegrationId(parsedInput.integrationId),
type: "workspaceTeam",
workspaceId: await getWorkspaceIdFromIntegrationId(parsedInput.integrationId),
minPermission: "readWrite",
},
],
@@ -17,9 +17,9 @@ import {
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { BaseSelectDropdown } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/BaseSelectDropdown";
import { fetchTables } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import AirtableLogo from "@/images/airtableLogo.svg";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -93,7 +93,7 @@ type EditModeProps =
type AddIntegrationModalProps = {
open: boolean;
setOpenWithStates: (v: boolean) => void;
environmentId: string;
workspaceId: string;
airtableArray: TIntegrationItem[];
surveys: TSurvey[];
airtableIntegration: TIntegrationAirtable;
@@ -103,8 +103,8 @@ const NoBaseFoundError = () => {
const { t } = useTranslation();
return (
<Alert>
<AlertTitle>{t("environments.integrations.airtable.no_bases_found")}</AlertTitle>
<AlertDescription>{t("environments.integrations.airtable.please_create_a_base")}</AlertDescription>
<AlertTitle>{t("workspace.integrations.airtable.no_bases_found")}</AlertTitle>
<AlertDescription>{t("workspace.integrations.airtable.please_create_a_base")}</AlertDescription>
</Alert>
);
};
@@ -172,7 +172,7 @@ const renderElementSelection = ({
export const AddIntegrationModal = ({
open,
setOpenWithStates,
environmentId,
workspaceId,
airtableArray,
surveys,
airtableIntegration,
@@ -227,19 +227,19 @@ export const AddIntegrationModal = ({
const submitHandler = async (data: IntegrationModalInputs) => {
try {
if (!data.base || data.base === "") {
throw new Error(t("environments.integrations.airtable.please_select_a_base"));
throw new Error(t("workspace.integrations.airtable.please_select_a_base"));
}
if (!data.table || data.table === "") {
throw new Error(t("environments.integrations.airtable.please_select_a_table"));
throw new Error(t("workspace.integrations.airtable.please_select_a_table"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (data.elements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
}
const currentTable = tables.find((item) => item.id === data.table);
@@ -270,7 +270,7 @@ export const AddIntegrationModal = ({
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: airtableIntegrationData,
});
if (result?.serverError) {
@@ -278,9 +278,9 @@ export const AddIntegrationModal = ({
return;
}
if (isEditMode) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
handleClose();
} catch (e) {
@@ -289,7 +289,7 @@ export const AddIntegrationModal = ({
};
const handleTable = async (baseId: string) => {
const data = await fetchTables(environmentId, baseId);
const data = await fetchTables(workspaceId, baseId);
if (data.tables) {
setTables(data.tables);
@@ -312,7 +312,7 @@ export const AddIntegrationModal = ({
const integrationData = structuredClone(airtableIntegrationData);
integrationData.config.data.splice(index, 1);
const result = await createOrUpdateIntegrationAction({ environmentId, integrationData });
const result = await createOrUpdateIntegrationAction({ workspaceId, integrationData });
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
@@ -320,7 +320,7 @@ export const AddIntegrationModal = ({
handleClose();
router.refresh();
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
} catch (e) {
toast.error(e instanceof Error ? e.message : "Unknown error occurred");
}
@@ -336,13 +336,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={AirtableLogo}
alt={t("environments.integrations.airtable.airtable_logo")}
alt={t("workspace.integrations.airtable.airtable_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.airtable.link_airtable_table")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.airtable.sync_responses_with_airtable")}
{t("workspace.integrations.airtable.sync_responses_with_airtable")}
</DialogDescription>
</div>
</div>
@@ -364,7 +364,7 @@ export const AddIntegrationModal = ({
)}
<div className="flex w-full flex-col">
<Label htmlFor="table">{t("environments.integrations.airtable.table_name")}</Label>
<Label htmlFor="table">{t("workspace.integrations.airtable.table_name")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
@@ -427,7 +427,7 @@ export const AddIntegrationModal = ({
</div>
) : (
<p className="m-1 text-xs text-slate-500">
{t("environments.integrations.create_survey_warning")}
{t("workspace.integrations.create_survey_warning")}
</p>
)}
@@ -5,38 +5,36 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/lib/airtable";
import airtableLogo from "@/images/airtableLogo.svg";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
interface AirtableWrapperProps {
environmentId: string;
workspaceId: string;
airtableArray: TIntegrationItem[];
airtableIntegration?: TIntegrationAirtable;
surveys: TSurvey[];
isEnabled: boolean;
webAppUrl: string;
locale: TUserLocale;
showReconnectButton?: boolean;
}
export const AirtableWrapper = ({
environmentId,
workspaceId,
airtableArray,
airtableIntegration,
surveys,
isEnabled,
webAppUrl,
locale,
showReconnectButton = false,
}: AirtableWrapperProps) => {
const [isConnected, setIsConnected] = useState(
airtableIntegration ? airtableIntegration.config?.key : false
);
const handleAirtableAuthorization = async () => {
authorize(environmentId, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -46,13 +44,11 @@ export const AirtableWrapper = ({
return isConnected && airtableIntegration ? (
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
workspaceId={workspaceId}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}
locale={locale}
showReconnectButton={showReconnectButton}
handleAirtableAuthorization={handleAirtableAuthorization}
/>
) : (
<ConnectIntegration
@@ -33,7 +33,7 @@ export const BaseSelectDropdown = ({
const { t } = useTranslation();
return (
<div className="flex w-full flex-col">
<Label htmlFor="base">{t("environments.integrations.airtable.airtable_base")}</Label>
<Label htmlFor="base">{t("workspace.integrations.airtable.airtable_base")}</Label>
<div className="mt-1 flex">
<Controller
control={control}
@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -8,43 +8,31 @@ import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AddIntegrationModal";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environmentId: string;
workspaceId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
locale: TUserLocale;
showReconnectButton: boolean;
handleAirtableAuthorization: () => Promise<void>;
}
export const ManageIntegration = ({
airtableIntegration,
environmentId,
setIsConnected,
surveys,
airtableArray,
showReconnectButton,
handleAirtableAuthorization,
locale,
}: ManageIntegrationProps) => {
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, workspaceId, setIsConnected, surveys, airtableArray } = props;
const { t } = useTranslation();
const tableHeaders = [
t("common.survey"),
t("environments.integrations.airtable.table_name"),
t("workspace.integrations.airtable.table_name"),
t("common.questions"),
t("common.updated_at"),
];
@@ -65,7 +53,7 @@ export const ManageIntegration = ({
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -85,40 +73,21 @@ export const ManageIntegration = ({
: { isEditMode: false as const };
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
<AlertButton onClick={handleAirtableAuthorization}>
{t("environments.integrations.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="mr-6 flex items-center">
<div className="flex w-full justify-end gap-x-6">
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
<span className="cursor-pointer text-slate-500">
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleAirtableAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setDefaultValues(null);
handleModal(true);
}}>
{t("environments.integrations.airtable.link_new_table")}
{t("workspace.integrations.airtable.link_new_table")}
</Button>
</div>
@@ -153,27 +122,29 @@ export const ManageIntegration = ({
<div className="col-span-2 text-center">{data.surveyName}</div>
<div className="col-span-2 text-center">{data.tableName}</div>
<div className="col-span-2 text-center">{data.elements}</div>
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
<div className="col-span-2 text-center">
{timeSince(data.createdAt.toString(), props.locale)}
</div>
</button>
))}
</div>
) : (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.airtable.no_integrations_yet")} />
<EmptyState text={t("workspace.integrations.airtable.no_integrations_yet")} />
</div>
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.airtable.airtable_integration")}
deleteWhat={t("workspace.integrations.airtable.airtable_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
@@ -182,7 +153,7 @@ export const ManageIntegration = ({
airtableArray={airtableArray}
open={isModalOpen}
setOpenWithStates={handleModal}
environmentId={environmentId}
workspaceId={workspaceId}
surveys={surveys}
airtableIntegration={airtableIntegration}
{...data}
@@ -13,7 +13,7 @@ vi.mock("@formbricks/logger", () => ({
// Mock fetch
global.fetch = vi.fn();
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const baseId = "test-base-id";
const apiHost = "http://localhost:3000";
@@ -36,11 +36,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const tables = await fetchTables(environmentId, baseId);
const tables = await fetchTables(workspaceId, baseId);
expect(fetch).toHaveBeenCalledWith(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
cache: "no-store",
});
expect(tables).toEqual(mockTables);
@@ -56,11 +56,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
});
expect(authUrl).toBe(mockAuthUrl);
});
@@ -73,11 +73,11 @@ describe("Airtable Library", () => {
};
vi.mocked(fetch).mockResolvedValue(mockResponse as Response);
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(fetch).toHaveBeenCalledWith(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId: workspaceId },
});
expect(logger.error).toHaveBeenCalledWith({ errorText }, "authorize: Could not fetch airtable config");
});
@@ -1,20 +1,20 @@
import { logger } from "@formbricks/logger";
import { TIntegrationAirtableTables } from "@formbricks/types/integration/airtable";
export const fetchTables = async (environmentId: string, baseId: string) => {
export const fetchTables = async (workspaceId: string, baseId: string) => {
const res = await fetch(`/api/v1/integrations/airtable/tables?baseId=${baseId}`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
cache: "no-store",
});
const resJson = await res.json();
return resJson.data as Promise<TIntegrationAirtableTables>;
};
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/v1/integrations/airtable`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {
@@ -1,29 +1,28 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { TIntegrationItem } from "@formbricks/types/integration";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { AirtableWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/airtable/components/AirtableWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { getAirtableTables } from "@/lib/airtable/service";
import { AIRTABLE_CLIENT_ID, DEFAULT_LOCALE, WEBAPP_URL } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
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 { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!AIRTABLE_CLIENT_ID;
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(workspace.id),
getIntegrations(workspace.id),
getUserLocale(session.user.id),
]);
@@ -32,14 +31,8 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
);
let airtableArray: TIntegrationItem[] = [];
let isTokenValid = true;
if (airtableIntegration?.config.key) {
try {
airtableArray = await getAirtableTables(params.environmentId);
} catch (error) {
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
isTokenValid = false;
}
airtableArray = await getAirtableTables(workspace.id);
}
if (isReadOnly) {
return redirect("./");
@@ -47,18 +40,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.airtable.airtable_integration")} />
<div className="h-[75vh] w-full">
<AirtableWrapper
isEnabled={isEnabled}
airtableIntegration={airtableIntegration}
airtableArray={airtableArray}
environmentId={environment.id}
workspaceId={workspace.id}
surveys={surveys}
webAppUrl={WEBAPP_URL}
locale={locale ?? DEFAULT_LOCALE}
showReconnectButton={!isTokenValid}
/>
</div>
</PageContentWrapper>
@@ -10,10 +10,10 @@ import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/go
import { getIntegrationByType } from "@/lib/integration/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
workspaceId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
@@ -21,21 +21,21 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: parsedInput.workspaceId,
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
const integration = await getIntegrationByType(parsedInput.workspaceId, "googleSheets");
if (!integration) {
return { data: false };
}
@@ -46,7 +46,7 @@ export const validateGoogleSheetsConnectionAction = authenticatedActionClient
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),
workspaceId: z.string(),
spreadsheetId: z.string(),
});
@@ -55,15 +55,15 @@ export const getSpreadsheetNameByIdAction = authenticatedActionClient
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
organizationId: await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
type: "workspaceTeam",
workspaceId: parsedInput.workspaceId,
minPermission: "readWrite",
},
],
@@ -12,13 +12,13 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { getSpreadsheetNameByIdAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import {
constructGoogleSheetsUrl,
extractSpreadsheetIdFromUrl,
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
@@ -44,7 +44,7 @@ import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps {
environmentId: string;
workspaceId: string;
open: boolean;
surveys: TSurvey[];
setOpen: (v: boolean) => void;
@@ -53,7 +53,7 @@ interface AddIntegrationModalProps {
}
export const AddIntegrationModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -125,9 +125,9 @@ export const AddIntegrationModal = ({
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
toast.error(t("workspace.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
toast.error(t("workspace.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
@@ -136,19 +136,19 @@ export const AddIntegrationModal = ({
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
throw new Error(t("environments.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
throw new Error(t("workspace.integrations.google_sheets.enter_a_valid_spreadsheet_url_error"));
}
if (!selectedSurvey) {
throw new Error(t("environments.integrations.please_select_a_survey_error"));
throw new Error(t("workspace.integrations.please_select_a_survey_error"));
}
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
throw new Error(t("workspace.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
environmentId,
workspaceId,
spreadsheetId,
});
@@ -180,7 +180,7 @@ export const AddIntegrationModal = ({
googleSheetIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
@@ -188,9 +188,9 @@ export const AddIntegrationModal = ({
return;
}
if (selectedIntegration) {
toast.success(t("environments.integrations.integration_updated_successfully"));
toast.success(t("workspace.integrations.integration_updated_successfully"));
} else {
toast.success(t("environments.integrations.integration_added_successfully"));
toast.success(t("workspace.integrations.integration_added_successfully"));
}
resetForm();
setOpen(false);
@@ -227,14 +227,14 @@ export const AddIntegrationModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: googleSheetIntegrationData,
});
if (result?.serverError) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setOpen(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Unknown error occurred");
@@ -253,13 +253,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={GoogleSheetLogo}
alt={t("environments.integrations.google_sheets.google_sheet_logo")}
alt={t("workspace.integrations.google_sheets.google_sheet_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.google_sheets.link_google_sheet")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.google_sheets.google_sheets_integration_description")}
{t("workspace.integrations.google_sheets.google_sheets_integration_description")}
</DialogDescription>
</div>
</div>
@@ -269,7 +269,7 @@ export const AddIntegrationModal = ({
<div className="w-full space-y-4">
<div>
<div className="mb-4">
<Label>{t("environments.integrations.google_sheets.spreadsheet_url")}</Label>
<Label>{t("workspace.integrations.google_sheets.spreadsheet_url")}</Label>
<Input
value={spreadsheetUrl}
onChange={(e) => setSpreadsheetUrl(e.target.value)}
@@ -286,7 +286,7 @@ export const AddIntegrationModal = ({
disabled={surveys.length === 0}
/>
<p className="m-1 text-xs text-slate-500">
{surveys.length === 0 && t("environments.integrations.create_survey_warning")}
{surveys.length === 0 && t("workspace.integrations.create_survey_warning")}
</p>
</div>
</div>
@@ -361,7 +361,7 @@ export const AddIntegrationModal = ({
<Button type="submit" loading={isLinkingSheet}>
{selectedIntegration
? t("common.update")
: t("environments.integrations.google_sheets.link_google_sheet")}
: t("workspace.integrations.google_sheets.link_google_sheet")}
</Button>
</DialogFooter>
</form>
@@ -1,16 +1,15 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
@@ -18,7 +17,7 @@ import { AddIntegrationModal } from "./AddIntegrationModal";
interface GoogleSheetWrapperProps {
isEnabled: boolean;
environment: TEnvironment;
workspaceId: string;
surveys: TSurvey[];
googleSheetIntegration?: TIntegrationGoogleSheets;
webAppUrl: string;
@@ -27,7 +26,7 @@ interface GoogleSheetWrapperProps {
export const GoogleSheetWrapper = ({
isEnabled,
environment,
workspaceId,
surveys,
googleSheetIntegration,
webAppUrl,
@@ -44,18 +43,18 @@ export const GoogleSheetWrapper = ({
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
const response = await validateGoogleSheetsConnectionAction({ workspaceId });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
}, [workspaceId, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -67,7 +66,7 @@ export const GoogleSheetWrapper = ({
{isConnected && googleSheetIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
workspaceId={workspaceId}
surveys={surveys}
open={isModalOpen}
setOpen={setIsModalOpen}
@@ -9,7 +9,7 @@ import {
TIntegrationGoogleSheetsConfigData,
} from "@formbricks/types/integration/google-sheet";
import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { deleteIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
@@ -53,7 +53,7 @@ export const ManageIntegration = ({
});
if (deleteIntegrationActionResult?.data) {
toast.success(t("environments.integrations.integration_removed_successfully"));
toast.success(t("workspace.integrations.integration_removed_successfully"));
setIsConnected(false);
} else {
const errorMessage = getFormattedErrorMessage(deleteIntegrationActionResult);
@@ -77,10 +77,10 @@ export const ManageIntegration = ({
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
{t("workspace.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
{t("workspace.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
@@ -88,7 +88,7 @@ export const ManageIntegration = ({
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
{t("environments.integrations.connected_with_email", {
{t("workspace.integrations.connected_with_email", {
email: googleSheetIntegration.config.email,
})}
</span>
@@ -98,11 +98,11 @@ export const ManageIntegration = ({
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
{t("workspace.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
{t("workspace.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -111,12 +111,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.google_sheets.link_new_sheet")}
{t("workspace.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.google_sheets.no_integrations_yet")} />
<EmptyState text={t("workspace.integrations.google_sheets.no_integrations_yet")} />
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -124,7 +124,7 @@ export const ManageIntegration = ({
<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="col-span-2 hidden text-center sm:block">{t("common.survey")}</div>
<div className="col-span-2 hidden text-center sm:block">
{t("environments.integrations.google_sheets.google_sheet_name")}
{t("workspace.integrations.google_sheets.google_sheet_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.questions")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
@@ -149,15 +149,15 @@ export const ManageIntegration = ({
)}
<Button variant="ghost" onClick={() => setIsDeleteIntegrationModalOpen(true)} className="mt-4">
<Trash2Icon />
{t("environments.integrations.delete_integration")}
{t("workspace.integrations.delete_integration")}
</Button>
<DeleteDialog
open={isDeleteIntegrationModalOpen}
setOpen={setIsDeleteIntegrationModalOpen}
deleteWhat={t("environments.integrations.google_sheets.google_connection")}
deleteWhat={t("workspace.integrations.google_sheets.google_connection")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.google_sheets.google_connection_deletion_description")}
text={t("workspace.integrations.google_sheets.google_connection_deletion_description")}
isDeleting={isDeleting}
/>
</div>
@@ -14,10 +14,10 @@ const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("authorize", () => {
const environmentId = "test-env-id";
const workspaceId = "test-env-id";
const apiHost = "http://test.com";
const expectedUrl = `${apiHost}/api/google-sheet`;
const expectedHeaders = { environmentId: environmentId };
const expectedHeaders = { workspaceId: workspaceId };
afterEach(() => {
vi.clearAllMocks();
@@ -30,7 +30,7 @@ describe("authorize", () => {
json: async () => ({ data: { authUrl: mockAuthUrl } }),
});
const authUrl = await authorize(environmentId, apiHost);
const authUrl = await authorize(workspaceId, apiHost);
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
@@ -47,7 +47,7 @@ describe("authorize", () => {
text: async () => errorText,
});
await expect(authorize(environmentId, apiHost)).rejects.toThrow("Could not create response");
await expect(authorize(workspaceId, apiHost)).rejects.toThrow("Could not create response");
expect(mockFetch).toHaveBeenCalledWith(expectedUrl, {
method: "GET",
@@ -1,9 +1,9 @@
import { logger } from "@formbricks/logger";
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
export const authorize = async (workspaceId: string, apiHost: string): Promise<string> => {
const res = await fetch(`${apiHost}/api/google-sheet`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {
@@ -11,7 +11,7 @@ const Loading = () => {
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.google_sheets.link_new_sheet")}
{t("workspace.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -19,7 +19,7 @@ const Loading = () => {
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-4 text-center">{t("common.survey")}</div>
<div className="col-span-4 text-center">
{t("environments.integrations.google_sheets.google_sheet_name")}
{t("workspace.integrations.google_sheets.google_sheet_name")}
</div>
<div className="col-span-2 text-center">{t("common.questions")}</div>
<div className="col-span-2 text-center">{t("common.updated_at")}</div>
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { GoogleSheetWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/google-sheets/components/GoogleSheetWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import {
DEFAULT_LOCALE,
GOOGLE_SHEETS_CLIENT_ID,
@@ -12,21 +12,21 @@ import {
import { getIntegrations } from "@/lib/integration/service";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
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 { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
const isEnabled = !!(GOOGLE_SHEETS_CLIENT_ID && GOOGLE_SHEETS_CLIENT_SECRET && GOOGLE_SHEETS_REDIRECT_URL);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, integrations, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),
getSurveys(workspace.id),
getIntegrations(workspace.id),
getUserLocale(session.user.id),
]);
@@ -39,12 +39,12 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
<GoBackButton url={`${WEBAPP_URL}/workspaces/${params.workspaceId}/integrations`} />
<PageHeader pageTitle={t("workspace.integrations.google_sheets.google_sheets_integration")} />
<div className="h-[75vh] w-full">
<GoogleSheetWrapper
isEnabled={isEnabled}
environment={environment}
workspaceId={workspace.id}
surveys={surveys}
googleSheetIntegration={googleSheetIntegration}
webAppUrl={WEBAPP_URL}

Some files were not shown because too many files have changed in this diff Show More