Compare commits

...

145 Commits

Author SHA1 Message Date
Johannes e82e0c87a4 fix: remove stale translation keys in PR6
Made-with: Cursor
2026-04-26 20:19:26 +02:00
Johannes f2f2defd10 fix: align Cube dev defaults with local Postgres
Update docker-compose dev defaults and env guidance so Cube connects to the local postgres service by default instead of a non-resolvable host.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 128e94e4cf fix: add missing dashboard chart translation keys
Add missing i18n strings for the add-chart label and create-new-chart action so dashboard dialogs render translated copy instead of raw key names.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes ee0ea7caa6 chore: finalize api keys, translations, and docs updates
Apply remaining admin API key UI tweaks, add matching locale copy updates, and include supporting development documentation for the question-bank workflow.

Made-with: Cursor
2026-04-26 20:17:00 +02:00
Johannes 52dc64ffd2 fix: add missing analysis translation keys in PR5
Made-with: Cursor
2026-04-26 20:16:47 +02:00
Johannes bec3fa2dbd feat: make Add charts a primary dashboard action
Promote Add charts to a labeled primary button in the dashboard control bar and keep secondary actions in the icon toolbar for clearer chart-creation affordance.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 18a3c4f0f7 fix: pass directories into dashboard detail chart dialogs
Load feedback record directories in dashboard detail page and thread them through dashboard detail and control bar components so chart dialogs no longer crash with an undefined directories reference.

Made-with: Cursor
2026-04-26 20:05:55 +02:00
Johannes 6c61afec2f 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-26 20:05:55 +02:00
Johannes ce4d9350e2 fix: add missing feedback record translation keys in PR4
Made-with: Cursor
2026-04-26 20:05:49 +02:00
Johannes 3e6f81268d fix: require frdId when refreshing feedback records
Made-with: Cursor
2026-04-26 19:54:30 +02:00
Johannes 8137de3c80 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-26 19:54:30 +02:00
Johannes bf0ad45697 fix: add missing feedback-directory and unify translations
Made-with: Cursor
2026-04-26 19:54:23 +02:00
Johannes b1a4277ca8 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-26 19:37:33 +02:00
Johannes 1876c13f52 fix: align unify locale keys and regenerate translations
Made-with: Cursor
2026-04-26 19:37:27 +02:00
Johannes 0623bb9ff5 fix: make feedback sources settings card compatible in PR2
Made-with: Cursor
2026-04-26 19:03:23 +02:00
Johannes d37cddaa7e 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-26 18:55:25 +02:00
Johannes 24f632f9ce fix: align unify connector type literals with schema
Replace legacy formbricks type checks with formbricks_survey across source setup flows so TypeScript and connector creation paths stay consistent.

Made-with: Cursor
2026-04-26 17:14:13 +02:00
Johannes b041e3da86 fix: normalize connector response timestamps for Cube
Normalize imported response timestamps to ISO and fall back from createdAt to updatedAt so collected_at is always valid for time-series charts.

Made-with: Cursor
2026-04-24 12:57:00 +02:00
Johannes 8d91a3db62 fix: map csat and ces connector question types
Add missing hub field mappings for csat and ces survey elements and guard against unmappable selected elements so connector setup fails with a clear validation error instead of a Prisma crash.

Made-with: Cursor
2026-04-24 12:02:51 +02:00
Johannes c05e3f192d fix: accept legacy connector type in create action
Normalize legacy formbricks connector payloads to formbricks_survey during connector creation so client flows continue working while UI migrations roll out.

Made-with: Cursor
2026-04-24 11:41:34 +02:00
Johannes 5b61e00560 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:56:57 +02:00
Dhruwang Jariwala bf592937f4 feat: AI-powered survey translation (#7793) 2026-04-24 12:55:36 +05:30
Bhagya Amarasinghe 7ed7101ac1 feat: adds feedback record directory auth to api keys (#7804) 2026-04-23 18:04:17 +05:30
pandeymangg 2e926936fb addressed feedback 2026-04-23 17:46:09 +05:30
Dhruwang a386451e6e refactor: consolidate imports in actions.ts
Merged the import statements for ZAITranslationField and translateFields from the translate-fields module to streamline the code structure.
2026-04-23 15:17:31 +05:30
Dhruwang f0bf111e7b fix: test 2026-04-23 15:08:18 +05:30
Dhruwang 8a57a5b74b addressed feedback 2026-04-23 15:05:16 +05:30
Dhruwang 434cb1d0d0 refactor: remove BullMQ background jobs from AI translation
Replace the async job queue + Redis polling pattern with a direct
server action call. The translation now runs synchronously inside
translateSurveyFieldsAction, removing the need for BullMQ job
definitions, processors, cache keys, and client-side polling logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:11:28 +05:30
pandeymangg 8bde75a9ff chore: merge with epic/v5 2026-04-23 11:06:54 +05:30
pandeymangg 6b880f29cb chore: merge with epic/v5 2026-04-23 11:00:25 +05:30
Dhruwang Jariwala 969c9834e5 fix: (Depr Env QA) update lang keys (#7786) 2026-04-23 10:57:47 +05:30
pandeymangg 5e33b7c9a4 fixes and e2e fixes 2026-04-23 10:55:20 +05:30
Dhruwang 230ea744fa fix: simplify workspace permissions assignment in ViewPermissionModal 2026-04-23 10:38:18 +05:30
Anshuman Pandey fae1fb8f96 fix: cleans up environmentId references (#7792) 2026-04-23 09:05:00 +04:00
Dhruwang eac35daed9 fix: transllations 2026-04-23 10:34:29 +05:30
Johannes 45accc1edb fix: align workspace naming in setup and email preview
Replace remaining environment wording with workspace terminology across setup flows, API key permissions, and email preview text, and switch the email Tailwind config to ESM so formatting hooks run under the current Node runtime.

Made-with: Cursor
2026-04-23 10:30:30 +05:30
Dhruwang Jariwala 02ebe8e9f8 fix: (Depr Env QA) update docs (#7784) 2026-04-23 10:23:23 +05:30
pandeymangg cae859e326 sonarcloud fixes 2026-04-22 17:15:19 +05:30
pandeymangg 5352d563b6 fixes and consistency 2026-04-22 17:03:18 +05:30
Anshuman Pandey 711f2bfe67 chore: restores feedback record directory changes to epic/v5 (#7806) 2026-04-22 15:22:15 +04:00
Dhruwang 6fcb5d39a2 fix: address code review feedback for AI translation
- Reuse ZAITranslationField from @formbricks/jobs instead of duplicating
  the schema locally in actions.ts; tighten sourceLanguage/targetLanguage
  validators with .min(1) to match the downstream job schema

- Guard against undefined translations in getAITranslationResultAction
  instead of using the unsafe `translations!` assertion — return
  "pending" status for malformed cache entries

- Use createCacheKey.custom("ai-translation", jobId) instead of raw
  template strings to follow cache key conventions

- Improve JSON parsing robustness: strip markdown code fences before
  attempting JSON.parse, log raw response on parse failures

- Add stale-request guard and error handling to the AI availability
  useEffect in language-view.tsx

- Replace shared pollingCancelledRef boolean with per-invocation Symbol
  token to prevent stale polling loops from clobbering state when the
  modal is reopened

- Track timeout explicitly with a timedOut flag so the "timed out" toast
  doesn't fire when polling was actually cancelled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:01:57 +05:30
pandeymangg 1ed9859ee7 fixes tests 2026-04-22 15:58:02 +05:30
pandeymangg cd72a0a78d adds translations 2026-04-22 15:44:45 +05:30
pandeymangg 2b09795787 feat: adds frd auth to api keys 2026-04-22 15:43:33 +05:30
Dhruwang 2451acb9bd fix: AI translation security, error handling, and test coverage
- Add userId verification in getAITranslationResultAction (security)
- Use OperationNotAllowedError for auth failures
- Store failure marker in cache on last BullMQ attempt
- Make JSON parsing more robust (extract first {...} block)
- Add "keep modal open" hint to translating toast
- Add test coverage for process-ai-translation-job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 15:18:54 +05:30
Dhruwang 14dcded91b Merge branch 'epic/v5' of https://github.com/formbricks/formbricks into feat/ai-survey-translation 2026-04-22 15:01:12 +05:30
Dhruwang 46062f91cd refactors 2026-04-22 14:50:46 +05:30
Dhruwang Jariwala ffd4478184 chore: merge epic/dashboards into epic/v5 (#7798)
Signed-off-by: gulshank0 <gulshanbahadur002@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Theodór Tómas <theodortomas@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: 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: Tiago <1585571+xernobyl@users.noreply.github.com>
Co-authored-by: Harsh Bhat <90265455+harshsbhat@users.noreply.github.com>
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@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>
Co-authored-by: Tiago Farto <tiago@formbricks.com>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 12:24:05 +04:00
Dhruwang 69da1862fa Merge latest epic/v5 (survey scheduling) into ai-translation branch
Resolve merge conflicts to combine AI translation and survey scheduling
features in jobs package, instrumentation, and tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 13:39:10 +05:30
Dhruwang c11d3241ab fix: test and translations 2026-04-22 12:59:27 +05:30
Tiago 3fb09a1a26 feat: survey scheduling (#7766) 2026-04-21 15:00:51 +00:00
Dhruwang 6efa449c10 feat: add AI-powered survey translation via BullMQ background jobs
Add a "Translate with AI" button to the Manage Translations modal that
auto-populates empty translation fields using the configured AI provider.
Translation runs as a BullMQ background job with results cached in Redis
and polled by the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 17:38:47 +05:30
Dhruwang 34b94689ca fix: address CodeRabbit review feedback on docs
- Fix broken API URLs in docs-feedback.mdx (remove /workspaces segment, fix https://, remove stray };)
- Add missing workspaceId path params to v2 spec (responses, displays, user)
- Remove environmentId from required arrays in v2 request schemas
- Fix stale terminology: environment→workspace in database-model, tenant-separation, tags, actions
- Fix broken link to removed test-environment page in webhooks.mdx
- Fix redundant "codebase" in naming-conventions description
- Use neutral hostname in audit-logging example
- Hyphenate "open-source" in license.mdx
- Consistent workspaceId formatting in wordpress.mdx
- Update link text to match anchor in actions.mdx
- Remove dual environmentId/workspaceId in headless-surveys example
- Fix stale <project_id> placeholder in headless-surveys
- Fix awkward Next.js card copy in framework-guides.mdx
- Clarify BC wording in v2 introduction.mdx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:35:26 +05:30
Tiago Farto 901fac7e08 chore: fix env vars 2026-04-21 09:56:32 +00:00
Dhruwang 739c662863 chore: merge epic/v5 and resolve openapi.json conflicts
Accept epic/v5 removals (attribute-classes, people endpoints) and
re-apply workspaceId rename + deprecation notes + endpoint cleanup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:22:17 +05:30
Dhruwang 535974ff8a fix: align API specs with actual routes and add environment deprecation notes
- Restore v1 openapi.json from main with environmentId→workspaceId rename
- Remove 5 non-existent v1 endpoints and their orphaned MDX pages
- Update v1 descriptions from "environment" to "workspace" terminology
- Add environment deprecation notes to all v1 client API endpoints
- Remove 2 non-existent v2 client endpoints (contacts attributes, identify)
- Rename v2 project-teams → workspace-teams (path, operationIds, schema)
- Preserve environment deprecation notes in v2 spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-21 15:15:52 +05:30
Tiago Farto a8b97abe9a Merge remote-tracking branch 'origin/epic/v5' into feat/survey-scheduling
# Conflicts:
#	pnpm-lock.yaml
2026-04-21 09:37:41 +00:00
Johannes 28103604b4 fix: (Depr Env QA) api v1/me regression (#7761)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-21 13:26:01 +04:00
Tiago Farto b5a7e15386 chore: fix build 2026-04-21 09:07:45 +00:00
Tiago Farto fec4746d5d chore: address PR comments 2026-04-21 08:51:32 +00:00
pandeymangg 175323e7d9 chore: merge with main 2026-04-21 14:07:14 +05:30
Tiago Farto 6130737d51 chore: fix DST bug 2026-04-20 16:57:48 +00:00
Tiago Farto bf10a8d0b2 Merge branch 'epic/v5' into feat/survey-scheduling 2026-04-20 16:09:16 +00:00
Tiago Farto 612f8dceb8 chore: fix test 2026-04-20 15:50:38 +00:00
Tiago 0303f16db4 feat: BullMQ background jobs + response pipeline (#7779) 2026-04-20 15:30:20 +00:00
Tiago Farto 07635b160e chore: fix test; migration 2026-04-20 15:13:27 +00:00
Tiago Farto 9cfcffdb5e chore: bug fix; tests 2026-04-20 14:22:06 +00:00
Tiago Farto 02264ffc5f chore: build fix 2026-04-20 13:25:01 +00:00
Tiago Farto 7dde3edd8d chore: fix tests 2026-04-20 12:30:03 +00:00
Johannes 4304a7efd6 Rename Projects to Workspaces in docs 2026-04-20 13:56:27 +02:00
Tiago Farto a89d598f8d Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:46:13 +00:00
Tiago Farto 6ff5af712f chore: clean tests 2026-04-20 11:35:47 +00:00
Anshuman Pandey 398ba79e7e feat: ces and csat questions (#7688)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-04-20 15:33:26 +04:00
Tiago Farto 5127de9de0 chore: revert CI action 2026-04-20 11:11:09 +00:00
Tiago Farto 2bf7788a1b Merge branch 'epic/bullmq' into feat/survey-scheduling 2026-04-20 11:07:33 +00:00
Tiago Farto ee8122778b chore: address PR comments 2026-04-20 10:43:32 +00:00
Tiago Farto 8aaa7ed9c0 chore: build fix 2026-04-20 10:00:06 +00:00
Johannes bc7c8c5715 remove environment ID andenv references 2026-04-20 11:40:33 +02:00
Dhruwang Jariwala ab1ea7a5ce fix: remove legacy API rewrites from next.config.mjs (#7764)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-20 13:38:30 +04:00
Tiago Farto 4f749355e0 chore: fix coverage test 2026-04-20 09:14:06 +00:00
Tiago Farto 18b60ddd35 chore: fix build 2026-04-20 08:52:59 +00:00
Tiago Farto 87f1b01c7a chore: fix broken tests 2026-04-20 08:40:44 +00:00
Tiago Farto 851ea0deb2 chore: fix broken lock 2026-04-20 08:32:45 +00:00
pandeymangg 9abbbfdd35 chore: merge with main 2026-04-20 13:07:36 +05:30
Johannes 990c0eee31 refined UX 2026-04-19 16:05:29 +02:00
Tiago Farto 07f16b8a43 chore: fix build 2026-04-17 23:23:51 +00:00
Tiago Farto bf556b0608 chore: fix linting 2026-04-17 22:26:57 +00:00
Tiago Farto 8b0766a46e chore: bix fuild 2026-04-17 22:16:17 +00:00
Tiago Farto 1f995d6e25 chore: build fix 2026-04-17 20:05:11 +00:00
Tiago Farto 975a4d57f8 chore: fix build 2026-04-17 19:50:23 +00:00
Tiago Farto 69bd576fc5 chore: fix build 2026-04-17 16:37:22 +00:00
Tiago Farto a2e4a3bbd7 chore: fix build 2026-04-17 16:27:18 +00:00
Tiago Farto 281f854332 chore: address PR comments 2026-04-17 15:36:12 +00:00
Tiago Farto 24496774a5 chore: fix build 2026-04-17 14:57:55 +00:00
Tiago Farto aeaf3215b4 chore: fix 2026-04-17 14:51:51 +00:00
Tiago Farto f4c5162590 Merge epic/bullmq into feat/survey-scheduling 2026-04-17 14:47:05 +00:00
Tiago Farto dedb7389f0 Merge origin/epic/v5 into epic/bullmq 2026-04-17 14:33:21 +00:00
Tiago Farto 7aed1b84de chore: translations, fixes 2026-04-17 11:59:17 +00:00
Bhagya Amarasinghe 9d2e988c59 feat: remove app rate limits for Envoy-covered routes (#7714)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-04-17 12:43:22 +04:00
Tiago 31d2ea7444 chore: Move Response Pipeline to BullMQ (#7695) 2026-04-15 10:12:41 +03:00
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
Tiago Farto d361c334d3 chore: fixed management snapshot gap 2026-04-13 14:28:31 +03:00
Tiago Farto a4d808b479 chore: build fix 2026-04-13 13:10:33 +03:00
Tiago Farto 18ae1748d3 chore: address PR comments 2026-04-13 12:50:21 +03:00
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
Tiago Farto 3404e0c494 chore: fix string date convertion error 2026-04-09 17:14:14 +03:00
Tiago Farto 83499ae552 chore: fix build 2026-04-09 15:14:44 +03:00
Tiago Farto 2ac0c1eb07 chore: refactor 2026-04-09 15:04:31 +03:00
Tiago Farto 54ede3015e chore: fix build 2026-04-09 14:09:46 +03:00
Tiago Farto 1b4f05a062 chore: fix linting issue 2026-04-09 13:59:34 +03:00
Tiago Farto 197dbf5aa6 chore: address pr comments 2026-04-09 13:45:32 +03:00
pandeymangg aa27d242bb chore: merge with main 2026-04-09 15:26:30 +05:30
Tiago 7ca52a7a93 feat: Introduce BullMQ setup to Formbricks (#7684) 2026-04-09 11:47:58 +03:00
Tiago Farto 4a48839d17 Merge branch 'feat/background_workers_v1' into chore/response-to-bullmq 2026-04-09 11:43:30 +03:00
Tiago Farto 92bd9bdac7 chore: address PR comments 2026-04-09 11:26:12 +03:00
Tiago Farto ad4b6f8b8c chore: addressing additional PR comments 2026-04-09 10:39:01 +03:00
Tiago Farto 8de5079db3 chore: lint fix 2026-04-09 10:07:29 +03:00
Tiago Farto a60206dd44 chore: fix sonarqube warnings 2026-04-09 09:59:09 +03:00
Tiago Farto d66abdcdaf chore: refactoring 2026-04-09 09:26:38 +03:00
Tiago Farto 03fa41a911 fix: tighten v2 response validation details typing 2026-04-08 23:23:37 +03:00
Tiago Farto cab438e474 chore: refactor 2026-04-08 21:47:15 +03:00
Tiago Farto a6dfe78c81 fix: restore response pipeline safety guards 2026-04-08 20:47:47 +03:00
Tiago Farto e4d96f4379 fix: resolve jobs runtime type import for web build 2026-04-08 17:16:17 +03:00
Tiago Farto 581a66b4a9 chore: fix problems 2026-04-08 17:00:36 +03:00
Tiago Farto 5cf0c15812 chore: response to bullmq 2026-04-08 14:43:50 +03:00
Tiago Farto ebaa2d363c chore: fix flaky test 2026-04-08 10:25:48 +03:00
Tiago Farto 597ea40b75 chore: fix linting issues 2026-04-08 10:16:24 +03:00
Tiago Farto 3c39dcc2de chore: increased test coverage 2026-04-08 09:51:58 +03:00
Tiago Farto e8df1dbb35 chore: fix sonarqube warning 2026-04-07 22:15:10 +03:00
Tiago Farto 84987ce557 chore: linter fixes 2026-04-07 21:42:23 +03:00
Tiago Farto 784ed855d7 chore: additional tests; address PR comments 2026-04-07 21:14:52 +03:00
Tiago Farto 5a17d4144d fix: normalize storage result typing for web build 2026-04-07 19:07:15 +03:00
Tiago Farto 65c9db86c6 fix: separate storage type exports and imports 2026-04-07 18:04:27 +03:00
Tiago Farto bc94d34d1e fix: narrow storage route results by property 2026-04-07 17:41:13 +03:00
Tiago Farto 22be60a0ba fix: align storage type exports for web build 2026-04-07 17:18:53 +03:00
Tiago Farto a384963863 fix: type storage delete wrappers 2026-04-07 16:34:51 +03:00
Tiago Farto c067ae73bb fix: narrow storage delete result in route 2026-04-07 16:25:36 +03:00
Tiago Farto dc78a30cbe fix: repair pnpm lockfile for BullMQ branch 2026-04-07 16:13:17 +03:00
Tiago Farto 9c9ae8a3a2 test: fix env test on v5 branch 2026-04-07 16:01:21 +03:00
Tiago Farto 29a08151aa chore: addressed PR concerns 2026-04-07 15:59:20 +03:00
Tiago Farto f42a8822a9 chore: background workers trough bullMQ 2026-04-07 15:56:12 +03:00
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
1552 changed files with 81155 additions and 45598 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
+46
View File
@@ -32,12 +32,39 @@ CRON_SECRET=
# Set the minimum log level(debug, info, warn, error, fatal)
LOG_LEVEL=info
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
# BULLMQ_WORKER_ENABLED=1
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
# Number of BullMQ worker instances started per Formbricks server process.
# BULLMQ_WORKER_COUNT=1
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
##############
# DATABASE #
##############
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 +296,24 @@ 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 local Postgres service by default in docker-compose.dev.yml.
# Override these only if your Hub DB runs on a different host.
# CUBEJS_DB_HOST=postgres
# CUBEJS_DB_PORT=5432
# CUBEJS_DB_NAME=postgres
# CUBEJS_DB_USER=postgres
# CUBEJS_DB_PASS=postgres
#
# Alternative (when not on same Docker network): host.docker.internal and port 5433
# Lingo.dev API key for translation generation
LINGO_API_KEY=your_api_key_here
+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
@@ -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,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,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,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,13 +5,13 @@ 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[];
@@ -21,7 +21,7 @@ interface AirtableWrapperProps {
}
export const AirtableWrapper = ({
environmentId,
workspaceId,
airtableArray,
airtableIntegration,
surveys,
@@ -34,7 +34,7 @@ export const AirtableWrapper = ({
);
const handleAirtableAuthorization = async () => {
authorize(environmentId, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -44,7 +44,7 @@ export const AirtableWrapper = ({
return isConnected && airtableIntegration ? (
<ManageIntegration
airtableArray={airtableArray}
environmentId={environmentId}
workspaceId={workspaceId}
airtableIntegration={airtableIntegration}
setIsConnected={setIsConnected}
surveys={surveys}
@@ -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}
@@ -8,8 +8,8 @@ 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 { Button } from "@/modules/ui/components/button";
@@ -19,7 +19,7 @@ import { IntegrationModalInputs } from "../lib/types";
interface ManageIntegrationProps {
airtableIntegration: TIntegrationAirtable;
environmentId: string;
workspaceId: string;
setIsConnected: (data: boolean) => void;
surveys: TSurvey[];
airtableArray: TIntegrationItem[];
@@ -27,12 +27,12 @@ interface ManageIntegrationProps {
}
export const ManageIntegration = (props: ManageIntegrationProps) => {
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
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"),
];
@@ -53,7 +53,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
});
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,7 +77,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
<div className="flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="cursor-pointer text-slate-500">
{t("environments.integrations.connected_with_email", {
{t("workspace.integrations.connected_with_email", {
email: airtableIntegration.config.email,
})}
</span>
@@ -87,7 +87,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
setDefaultValues(null);
handleModal(true);
}}>
{t("environments.integrations.airtable.link_new_table")}
{t("workspace.integrations.airtable.link_new_table")}
</Button>
</div>
@@ -130,21 +130,21 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
</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}
/>
@@ -153,7 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
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,28 +1,28 @@
import { redirect } from "next/navigation";
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,7 +32,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
let airtableArray: TIntegrationItem[] = [];
if (airtableIntegration?.config.key) {
airtableArray = await getAirtableTables(params.environmentId);
airtableArray = await getAirtableTables(workspace.id);
}
if (isReadOnly) {
return redirect("./");
@@ -40,14 +40,14 @@ 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}
@@ -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}
@@ -35,7 +35,7 @@ vi.mock("react", async (importOriginal) => {
};
});
const environmentId = "test-environment-id";
const workspaceId = "test-environment-id";
// Use 'as any' to bypass complex type matching for mock data
const mockPrismaSurveys = [
{ id: "survey1", name: "Survey 1", status: "inProgress", updatedAt: new Date() },
@@ -58,7 +58,7 @@ const mockTransformedSurveys: TSurvey[] = [
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app", // Changed type to web to match original file
environmentId: environmentId,
workspaceId: workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
@@ -80,7 +80,7 @@ const mockTransformedSurveys: TSurvey[] = [
welcomeCard: { enabled: false } as unknown as TSurvey["welcomeCard"],
hiddenFields: { enabled: false },
type: "app",
environmentId: environmentId,
workspaceId: workspaceId,
createdAt: new Date(),
updatedAt: new Date(),
languages: [],
@@ -98,14 +98,14 @@ describe("getSurveys", () => {
return { ...found } as TSurvey;
});
const surveys = await getSurveys(environmentId);
const surveys = await getSurveys(workspaceId);
expect(surveys).toEqual(mockTransformedSurveys);
// Use expect.any(ZId) for the Zod schema validation check
expect(validateInputs).toHaveBeenCalledWith([environmentId, expect.any(Object)]); // Adjusted expectation
expect(validateInputs).toHaveBeenCalledWith([workspaceId, expect.any(Object)]); // Adjusted expectation
expect(prisma.survey.findMany).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId,
status: {
not: "completed",
},
@@ -129,7 +129,7 @@ describe("getSurveys", () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(prismaError);
await expect(getSurveys(environmentId)).rejects.toThrow(DatabaseError);
await expect(getSurveys(workspaceId)).rejects.toThrow(DatabaseError);
expect(logger.error).toHaveBeenCalledWith({ error: prismaError }, "getSurveys: Could not fetch surveys");
// React cache is already mocked globally - no need to check it here
});
@@ -139,7 +139,7 @@ describe("getSurveys", () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(genericError);
await expect(getSurveys(environmentId)).rejects.toThrow(genericError);
await expect(getSurveys(workspaceId)).rejects.toThrow(genericError);
expect(logger.error).not.toHaveBeenCalled();
// React cache is already mocked globally - no need to check it here
});
@@ -10,13 +10,13 @@ import { selectSurvey } from "@/lib/survey/service";
import { transformPrismaSurvey } from "@/lib/survey/utils";
import { validateInputs } from "@/lib/utils/validate";
export const getSurveys = reactCache(async (environmentId: string): Promise<TSurvey[]> => {
validateInputs([environmentId, ZId]);
export const getSurveys = reactCache(async (workspaceId: string): Promise<TSurvey[]> => {
validateInputs([workspaceId, ZId]);
try {
const surveysPrisma = await prisma.survey.findMany({
where: {
environmentId,
workspaceId,
status: {
not: "completed",
},
@@ -14,7 +14,7 @@ vi.mock("@formbricks/database", () => ({
},
}));
const environmentId = "test-environment-id";
const workspaceId = "test-workspace-id";
const sourceZapier = "zapier";
describe("getWebhookCountBySource", () => {
@@ -26,16 +26,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 5;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId, sourceZapier);
const count = await getWebhookCountBySource(workspaceId, sourceZapier);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[workspaceId, expect.any(Object)],
[sourceZapier, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId: workspaceId,
source: sourceZapier,
},
});
@@ -45,16 +45,16 @@ describe("getWebhookCountBySource", () => {
const mockCount = 10;
vi.mocked(prisma.webhook.count).mockResolvedValue(mockCount);
const count = await getWebhookCountBySource(environmentId);
const count = await getWebhookCountBySource(workspaceId);
expect(count).toBe(mockCount);
expect(validateInputs).toHaveBeenCalledWith(
[environmentId, expect.any(Object)],
[workspaceId, expect.any(Object)],
[undefined, expect.any(Object)]
);
expect(prisma.webhook.count).toHaveBeenCalledWith({
where: {
environmentId,
workspaceId: workspaceId,
source: undefined,
},
});
@@ -67,7 +67,7 @@ describe("getWebhookCountBySource", () => {
});
vi.mocked(prisma.webhook.count).mockRejectedValue(prismaError);
await expect(getWebhookCountBySource(environmentId, sourceZapier)).rejects.toThrow(DatabaseError);
await expect(getWebhookCountBySource(workspaceId, sourceZapier)).rejects.toThrow(DatabaseError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
});
@@ -75,7 +75,7 @@ describe("getWebhookCountBySource", () => {
const genericError = new Error("Something went wrong");
vi.mocked(prisma.webhook.count).mockRejectedValue(genericError);
await expect(getWebhookCountBySource(environmentId)).rejects.toThrow(genericError);
await expect(getWebhookCountBySource(workspaceId)).rejects.toThrow(genericError);
expect(prisma.webhook.count).toHaveBeenCalledTimes(1);
});
});
@@ -6,15 +6,15 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const getWebhookCountBySource = async (
environmentId: string,
workspaceId: string,
source?: Webhook["source"]
): Promise<number> => {
validateInputs([environmentId, ZId], [source, z.string().optional()]);
validateInputs([workspaceId, ZId], [source, z.string().optional()]);
try {
const count = await prisma.webhook.count({
where: {
environmentId,
workspaceId,
source,
},
});
@@ -15,12 +15,12 @@ import {
import { TSurveyElementTypeEnum } 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 { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import {
MappingRow,
TMapping,
createEmptyMapping,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/MappingRow";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/MappingRow";
import NotionLogo from "@/images/notion.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -39,7 +39,7 @@ import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
interface AddIntegrationModalProps {
environmentId: string;
workspaceId: string;
surveys: TSurvey[];
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -49,7 +49,7 @@ interface AddIntegrationModalProps {
}
export const AddIntegrationModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -175,18 +175,18 @@ export const AddIntegrationModal = ({
const linkDatabase = async () => {
try {
if (!selectedDatabase) {
throw new Error(t("environments.integrations.notion.please_select_a_database"));
throw new Error(t("workspace.integrations.notion.please_select_a_database"));
}
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 (mapping.length === 1 && (!mapping[0].element.id || !mapping[0].column.id)) {
throw new Error(t("environments.integrations.notion.please_select_at_least_one_mapping"));
throw new Error(t("workspace.integrations.notion.please_select_at_least_one_mapping"));
}
if (mapping.filter((m) => m.error).length > 0) {
throw new Error(t("environments.integrations.notion.please_resolve_mapping_errors"));
throw new Error(t("workspace.integrations.notion.please_resolve_mapping_errors"));
}
if (
@@ -194,7 +194,7 @@ export const AddIntegrationModal = ({
mapping.filter((m) => m.element.id && !m.column.id).length >= 1
) {
throw new Error(
t("environments.integrations.notion.please_complete_mapping_fields_with_notion_property")
t("workspace.integrations.notion.please_complete_mapping_fields_with_notion_property")
);
}
@@ -219,7 +219,7 @@ export const AddIntegrationModal = ({
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: notionIntegrationData,
});
if (result?.serverError) {
@@ -227,9 +227,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);
@@ -245,14 +245,14 @@ export const AddIntegrationModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: notionIntegrationData,
});
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");
@@ -283,13 +283,13 @@ export const AddIntegrationModal = ({
fill
className="object-contain object-center"
src={NotionLogo}
alt={t("environments.integrations.notion.notion_logo")}
alt={t("workspace.integrations.notion.notion_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.notion.link_notion_database")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.notion.link_notion_database")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.notion.notion_integration_description")}
{t("workspace.integrations.notion.notion_integration_description")}
</DialogDescription>
</div>
</div>
@@ -301,7 +301,7 @@ export const AddIntegrationModal = ({
<div>
<div className="mb-4">
<DropdownSelector
label={t("environments.integrations.notion.select_a_database")}
label={t("workspace.integrations.notion.select_a_database")}
items={databases.map((d) => ({
id: d.id,
name: (d as any).title?.[0]?.plain_text,
@@ -314,13 +314,13 @@ export const AddIntegrationModal = ({
{selectedDatabase && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>{t("common.warning")}:</strong>{" "}
{t("environments.integrations.notion.duplicate_connection_warning")}
{t("workspace.integrations.notion.duplicate_connection_warning")}
</p>
)}
<p className="m-1 text-xs text-slate-500">
{databases.length === 0 &&
t(
"environments.integrations.notion.create_at_least_one_database_to_setup_this_integration"
"workspace.integrations.notion.create_at_least_one_database_to_setup_this_integration"
)}
</p>
</div>
@@ -333,13 +333,13 @@ 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>
{selectedDatabase && selectedSurvey && (
<div>
<Label>
{t("environments.integrations.notion.map_formbricks_fields_to_notion_property")}
{t("workspace.integrations.notion.map_formbricks_fields_to_notion_property")}
</Label>
<div className="mt-1 space-y-2 overflow-y-auto">
{mapping.map((m, idx) => (
@@ -388,7 +388,7 @@ export const AddIntegrationModal = ({
type="submit"
loading={isLinkingDatabase}
disabled={mapping.filter((m) => m.error).length > 0}>
{selectedIntegration ? t("common.update") : t("environments.integrations.notion.link_database")}
{selectedIntegration ? t("common.update") : t("workspace.integrations.notion.link_database")}
</Button>
</DialogFooter>
</form>
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
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 { Button } from "@/modules/ui/components/button";
@@ -50,7 +50,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);
@@ -72,7 +72,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.notion.connected_with_workspace", {
{t("workspace.integrations.notion.connected_with_workspace", {
workspace: notionIntegration.config.key.workspace_name,
})}
</span>
@@ -82,10 +82,10 @@ export const ManageIntegration = ({
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleNotionAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.notion.update_connection")}
{t("workspace.integrations.notion.update_connection")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("environments.integrations.notion.update_connection_tooltip")}</TooltipContent>
<TooltipContent>{t("workspace.integrations.notion.update_connection_tooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
@@ -93,12 +93,12 @@ export const ManageIntegration = ({
setSelectedIntegration(null);
setOpenAddIntegrationModal(true);
}}>
{t("environments.integrations.notion.link_new_database")}
{t("workspace.integrations.notion.link_new_database")}
</Button>
</div>
{!integrationArray || integrationArray.length === 0 ? (
<div className="mt-4 w-full">
<EmptyState text={t("environments.integrations.notion.no_databases_found")} />
<EmptyState text={t("workspace.integrations.notion.no_databases_found")} />
</div>
) : (
<div className="mt-4 flex w-full flex-col items-center justify-center">
@@ -106,7 +106,7 @@ export const ManageIntegration = ({
<div className="grid h-12 grid-cols-6 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.notion.database_name")}
{t("workspace.integrations.notion.database_name")}
</div>
<div className="col-span-2 hidden text-center sm:block">{t("common.updated_at")}</div>
</div>
@@ -129,15 +129,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.notion.notion_integration")}
deleteWhat={t("workspace.integrations.notion.notion_integration")}
onDelete={handleDeleteIntegration}
text={t("environments.integrations.delete_integration_confirmation")}
text={t("workspace.integrations.delete_integration_confirmation")}
isDeleting={isDeleting}
/>
</div>
@@ -8,7 +8,7 @@ import {
ERRORS,
TYPE_MAPPING,
UNSUPPORTED_TYPES_BY_NOTION,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
} from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/constants";
import { structuredClone } from "@/lib/pollyfills/structuredClone";
import { getElementTypes } from "@/modules/survey/lib/elements";
import { Button } from "@/modules/ui/components/button";
@@ -48,7 +48,7 @@ const MappingErrorMessage = ({
return (
<>
-{" "}
{t("environments.integrations.notion.col_name_of_type_is_not_supported", {
{t("workspace.integrations.notion.col_name_of_type_is_not_supported", {
col_name: col.name,
type: col.type,
})}
@@ -60,7 +60,7 @@ const MappingErrorMessage = ({
if (!element) return null;
return (
<>
{t("environments.integrations.notion.que_name_of_type_cant_be_mapped_to", {
{t("workspace.integrations.notion.que_name_of_type_cant_be_mapped_to", {
que_name: elem.name,
question_label: element.label,
col_name: col.name,
@@ -194,7 +194,7 @@ export const MappingRow = ({
<div className="flex w-full items-center">
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_survey_question")}
placeholder={t("workspace.integrations.notion.select_a_survey_question")}
items={filteredElementItems}
selectedItem={mapping?.[idx]?.element}
setSelectedItem={handleElementSelect}
@@ -204,7 +204,7 @@ export const MappingRow = ({
<div className="h-px w-4 border-t border-t-slate-300" />
<div className="max-w-full flex-1">
<DropdownSelector
placeholder={t("environments.integrations.notion.select_a_field_to_map")}
placeholder={t("workspace.integrations.notion.select_a_field_to_map")}
items={getFilteredDbItems()}
selectedItem={mapping?.[idx]?.column}
setSelectedItem={handleColumnSelect}
@@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationNotion,
TIntegrationNotionConfigData,
@@ -9,8 +8,8 @@ import {
} from "@formbricks/types/integration/notion";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
import { AddIntegrationModal } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/AddIntegrationModal";
import { ManageIntegration } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/ManageIntegration";
import notionLogo from "@/images/notion.png";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { authorize } from "../lib/notion";
@@ -18,7 +17,7 @@ import { authorize } from "../lib/notion";
interface NotionWrapperProps {
notionIntegration: TIntegrationNotion | undefined;
enabled: boolean;
environment: TEnvironment;
workspaceId: string;
webAppUrl: string;
surveys: TSurvey[];
databasesArray: TIntegrationNotionDatabase[];
@@ -28,7 +27,7 @@ interface NotionWrapperProps {
export const NotionWrapper = ({
notionIntegration,
enabled,
environment,
workspaceId,
webAppUrl,
surveys,
databasesArray,
@@ -43,7 +42,7 @@ export const NotionWrapper = ({
>(null);
const handleNotionAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
authorize(workspaceId, webAppUrl).then((url: string) => {
if (url) {
window.location.replace(url);
}
@@ -55,7 +54,7 @@ export const NotionWrapper = ({
{isConnected && notionIntegration ? (
<>
<AddIntegrationModal
environmentId={environment.id}
workspaceId={workspaceId}
surveys={surveys}
open={isModalOpen}
setOpen={setIsModalOpen}
@@ -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/v1/integrations/notion`;
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/v1/integrations/notion`, {
method: "GET",
headers: { environmentId: environmentId },
headers: { workspaceId },
});
if (!res.ok) {
@@ -11,14 +11,14 @@ 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.notion.link_database")}
{t("workspace.integrations.notion.link_database")}
</Button>
</div>
<div className="rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-6 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2 text-center">{t("common.survey")}</div>
<div className="col-span-2 text-center">{t("environments.integrations.notion.database_name")}</div>
<div className="col-span-2 text-center">{t("workspace.integrations.notion.database_name")}</div>
<div className="col-span-2 text-center">{t("common.updated_at")}</div>
</div>
<div className="grid-cols-7">
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
import { getSurveys } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/surveys";
import { NotionWrapper } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/notion/components/NotionWrapper";
import {
DEFAULT_LOCALE,
NOTION_AUTH_URL,
@@ -14,12 +14,12 @@ import { getIntegrationByType } from "@/lib/integration/service";
import { getNotionDatabases } from "@/lib/notion/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 enabled = !!(
@@ -29,17 +29,17 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
NOTION_REDIRECT_URI
);
const { isReadOnly, environment, session } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, session, workspace } = await getWorkspaceAuth(params.workspaceId);
const [surveys, notionIntegration, locale] = await Promise.all([
getSurveys(params.environmentId),
getIntegrationByType(params.environmentId, "notion"),
getSurveys(workspace.id),
getIntegrationByType(workspace.id, "notion"),
getUserLocale(session.user.id),
]);
let databasesArray: TIntegrationNotionDatabase[] = [];
if (notionIntegration && (notionIntegration as TIntegrationNotion).config.key?.bot_id) {
databasesArray = (await getNotionDatabases(environment.id)) ?? [];
databasesArray = (await getNotionDatabases(workspace.id)) ?? [];
}
if (isReadOnly) {
@@ -49,11 +49,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<GoBackButton url={"./"} />
<PageHeader pageTitle={t("environments.integrations.notion.notion_integration")} />
<PageHeader pageTitle={t("workspace.integrations.notion.notion_integration")} />
<NotionWrapper
enabled={enabled}
surveys={surveys}
environment={environment}
workspaceId={workspace.id}
notionIntegration={notionIntegration as TIntegrationNotion}
webAppUrl={WEBAPP_URL}
databasesArray={databasesArray}
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
import Image from "next/image";
import { redirect } from "next/navigation";
import { TIntegrationType } from "@formbricks/types/integration";
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
import { getWebhookCountBySource } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/lib/webhook";
import ActivePiecesLogo from "@/images/activepieces.webp";
import AirtableLogo from "@/images/airtableLogo.svg";
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
@@ -17,11 +17,11 @@ import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getIntegrations } from "@/lib/integration/service";
import { getBillingFallbackPath } from "@/lib/membership/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ProjectConfigNavigation } from "@/modules/projects/settings/components/project-config-navigation";
import { Card } from "@/modules/ui/components/integration-card";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
const getStatusText = (count: number, t: TFunction, type: string) => {
if (count === 1) return `1 ${type}`;
@@ -29,11 +29,11 @@ const getStatusText = (count: number, t: TFunction, type: string) => {
return `${count} ${type}s`;
};
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 { isReadOnly, environment, isBilling } = await getEnvironmentAuth(params.environmentId);
const { isReadOnly, isBilling, workspace } = await getWorkspaceAuth(params.workspaceId);
const [
integrations,
@@ -43,19 +43,19 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
n8nwebhookCount,
activePiecesWebhookCount,
] = await Promise.all([
getIntegrations(params.environmentId),
getWebhookCountBySource(params.environmentId, "user"),
getWebhookCountBySource(params.environmentId, "zapier"),
getWebhookCountBySource(params.environmentId, "make"),
getWebhookCountBySource(params.environmentId, "n8n"),
getWebhookCountBySource(params.environmentId, "activepieces"),
getIntegrations(workspace.id),
getWebhookCountBySource(workspace.id, "user"),
getWebhookCountBySource(workspace.id, "zapier"),
getWebhookCountBySource(workspace.id, "make"),
getWebhookCountBySource(workspace.id, "n8n"),
getWebhookCountBySource(workspace.id, "activepieces"),
]);
const isIntegrationConnected = (type: TIntegrationType) =>
integrations.some((integration) => integration.type === type);
if (isBilling) {
return redirect(getBillingFallbackPath(params.environmentId, IS_FORMBRICKS_CLOUD));
return redirect(getBillingFallbackPath(params.workspaceId, IS_FORMBRICKS_CLOUD));
}
const isGoogleSheetsIntegrationConnected = isIntegrationConnected("googleSheets");
@@ -64,7 +64,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const isN8nIntegrationConnected = isIntegrationConnected("n8n");
const isSlackIntegrationConnected = isIntegrationConnected("slack");
const appSetupCompleted = !!environment?.appSetupCompleted;
const appSetupCompleted = !!workspace.appSetupCompleted;
const integrationCards = [
{
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/zapier",
@@ -74,63 +74,63 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Zapier",
description: t("environments.integrations.zapier_integration_description"),
description: t("workspace.integrations.zapier_integration_description"),
icon: <Image src={ZapierLogo} alt="Zapier Logo" />,
connected: zapierWebhookCount > 0,
statusText: getStatusText(zapierWebhookCount, t, "zap"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
connectText: t("environments.integrations.manage_webhooks"),
connectHref: `/workspaces/${params.workspaceId}/integrations/webhooks`,
connectText: t("workspace.integrations.manage_webhooks"),
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
docsText: t("common.docs"),
docsNewTab: true,
label: "Webhooks",
description: t("environments.integrations.webhook_integration_description"),
description: t("workspace.integrations.webhook_integration_description"),
icon: <Image src={WebhookLogo} alt="Webhook Logo" />,
connected: userWebhookCount > 0,
statusText: getStatusText(userWebhookCount, t, "webhook"),
disabled: false,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
connectHref: `/workspaces/${params.workspaceId}/integrations/google-sheets`,
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
docsText: t("common.docs"),
docsNewTab: true,
label: "Google Sheets",
description: t("environments.integrations.google_sheet_integration_description"),
description: t("workspace.integrations.google_sheet_integration_description"),
icon: <Image src={GoogleSheetsLogo} alt="Google sheets Logo" />,
connected: isGoogleSheetsIntegrationConnected,
statusText: isGoogleSheetsIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
connectHref: `/workspaces/${params.workspaceId}/integrations/airtable`,
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
docsText: t("common.docs"),
docsNewTab: true,
label: "Airtable",
description: t("environments.integrations.airtable_integration_description"),
description: t("workspace.integrations.airtable_integration_description"),
icon: <Image src={AirtableLogo} alt="Airtable Logo" />,
connected: isAirtableIntegrationConnected,
statusText: isAirtableIntegrationConnected ? t("common.connected") : t("common.not_connected"),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
connectHref: `/workspaces/${params.workspaceId}/integrations/slack`,
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
docsText: t("common.docs"),
docsNewTab: true,
label: "Slack",
description: t("environments.integrations.slack_integration_description"),
description: t("workspace.integrations.slack_integration_description"),
icon: <Image src={SlackLogo} alt="Slack Logo" />,
connected: isSlackIntegrationConnected,
statusText: isSlackIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -144,7 +144,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectHref: "https://n8n.io",
connectNewTab: true,
label: "n8n",
description: t("environments.integrations.n8n_integration_description"),
description: t("workspace.integrations.n8n_integration_description"),
icon: <Image src={n8nLogo} alt="n8n Logo" />,
connected: n8nwebhookCount > 0,
statusText: getStatusText(n8nwebhookCount, t, t("common.integration")),
@@ -158,21 +158,21 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Make.com",
description: t("environments.integrations.make_integration_description"),
description: t("workspace.integrations.make_integration_description"),
icon: <Image src={MakeLogo} alt="Make Logo" />,
connected: makeWebhookCount > 0,
statusText: getStatusText(makeWebhookCount, t, t("common.integration")),
disabled: isReadOnly,
},
{
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
connectHref: `/workspaces/${params.workspaceId}/integrations/notion`,
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
connectNewTab: false,
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
docsText: t("common.docs"),
docsNewTab: true,
label: "Notion",
description: t("environments.integrations.notion_integration_description"),
description: t("workspace.integrations.notion_integration_description"),
icon: <Image src={notionLogo} alt="Notion Logo" />,
connected: isNotionIntegrationConnected,
statusText: isNotionIntegrationConnected ? t("common.connected") : t("common.not_connected"),
@@ -186,7 +186,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
connectText: t("common.connect"),
connectNewTab: true,
label: "Activepieces",
description: t("environments.integrations.activepieces_integration_description"),
description: t("workspace.integrations.activepieces_integration_description"),
icon: <Image src={ActivePiecesLogo} alt="ActivePieces Logo" />,
connected: activePiecesWebhookCount > 0,
statusText: getStatusText(activePiecesWebhookCount, t, t("common.integration")),
@@ -198,11 +198,11 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
docsText: t("common.docs"),
docsNewTab: true,
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
connectHref: `/workspaces/${params.workspaceId}/app-connection`,
connectText: t("common.connect"),
connectNewTab: false,
label: "Javascript SDK",
description: t("environments.integrations.website_or_app_integration_description"),
description: t("workspace.integrations.website_or_app_integration_description"),
icon: <Image src={JsLogo} alt="Javascript Logo" />,
connected: appSetupCompleted,
statusText: appSetupCompleted ? t("common.connected") : t("common.not_connected"),
@@ -212,7 +212,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
return (
<PageContentWrapper>
<PageHeader pageTitle={t("common.workspace_configuration")}>
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
<WorkspaceConfigNavigation activeId="integrations" />
</PageHeader>
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
{integrationCards.map((card) => (
@@ -5,10 +5,10 @@ import { ZId } from "@formbricks/types/common";
import { getSlackChannels } from "@/lib/slack/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 ZGetSlackChannelsAction = z.object({
environmentId: ZId,
workspaceId: ZId,
});
export const getSlackChannelsAction = authenticatedActionClient
@@ -16,19 +16,19 @@ export const getSlackChannelsAction = 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",
},
],
});
return await getSlackChannels(parsedInput.environmentId);
return await getSlackChannels(parsedInput.workspaceId);
});
@@ -15,7 +15,7 @@ import {
} from "@formbricks/types/integration/slack";
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 { createOrUpdateIntegrationAction } from "@/app/(app)/workspaces/[workspaceId]/(workspace)/integrations/actions";
import SlackLogo from "@/images/slacklogo.png";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
@@ -36,7 +36,7 @@ import { DropdownSelector } from "@/modules/ui/components/dropdown-selector";
import { Label } from "@/modules/ui/components/label";
interface AddChannelMappingModalProps {
environmentId: string;
workspaceId: string;
surveys: TSurvey[];
open: boolean;
setOpen: (v: boolean) => void;
@@ -46,7 +46,7 @@ interface AddChannelMappingModalProps {
}
export const AddChannelMappingModal = ({
environmentId,
workspaceId,
surveys,
open,
setOpen,
@@ -112,14 +112,14 @@ export const AddChannelMappingModal = ({
const linkChannel = async () => {
try {
if (!selectedChannel) {
throw new Error(t("environments.integrations.slack.please_select_a_channel"));
throw new Error(t("workspace.integrations.slack.please_select_a_channel"));
}
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"));
}
setIsLinkingChannel(true);
const integrationData: TIntegrationSlackConfigData = {
@@ -146,7 +146,7 @@ export const AddChannelMappingModal = ({
slackIntegrationData.config.data.push(integrationData);
}
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: slackIntegrationData,
});
if (result?.serverError) {
@@ -154,9 +154,9 @@ export const AddChannelMappingModal = ({
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);
@@ -190,14 +190,14 @@ export const AddChannelMappingModal = ({
try {
setIsDeleting(true);
const result = await createOrUpdateIntegrationAction({
environmentId,
workspaceId,
integrationData: slackIntegrationData,
});
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");
@@ -227,13 +227,13 @@ export const AddChannelMappingModal = ({
fill
className="object-contain object-center"
src={SlackLogo}
alt={t("environments.integrations.slack.slack_logo")}
alt={t("workspace.integrations.slack.slack_logo")}
/>
</div>
<div className="space-y-0.5">
<DialogTitle>{t("environments.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogTitle>{t("workspace.integrations.slack.link_slack_channel")}</DialogTitle>
<DialogDescription>
{t("environments.integrations.slack.slack_integration_description")}
{t("workspace.integrations.slack.slack_integration_description")}
</DialogDescription>
</div>
</div>
@@ -244,7 +244,7 @@ export const AddChannelMappingModal = ({
<div>
<div className="mb-4">
<DropdownSelector
label={t("environments.integrations.slack.select_channel")}
label={t("workspace.integrations.slack.select_channel")}
items={channels}
selectedItem={selectedChannel}
setSelectedItem={setSelectedChannel}
@@ -256,18 +256,18 @@ export const AddChannelMappingModal = ({
className="text-xs">
<Button variant="ghost" size="sm" className="my-2" type="button">
<CircleHelpIcon className="h-4 w-4" />
{t("environments.integrations.slack.dont_see_your_channel")}
{t("workspace.integrations.slack.dont_see_your_channel")}
</Button>
</Link>
{selectedChannel && hasMatchingId && (
<p className="text-xs text-amber-700">
<strong>{t("common.note")}:</strong>{" "}
{t("environments.integrations.slack.already_connected_another_survey")}
{t("workspace.integrations.slack.already_connected_another_survey")}
</p>
)}
<p className="m-1 text-xs text-slate-500">
{channels.length === 0 &&
t("environments.integrations.slack.create_at_least_one_channel_error")}
t("workspace.integrations.slack.create_at_least_one_channel_error")}
</p>
</div>
<div>
@@ -279,7 +279,7 @@ export const AddChannelMappingModal = ({
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>
@@ -346,7 +346,7 @@ export const AddChannelMappingModal = ({
</Button>
)}
<Button type="submit" loading={isLinkingChannel}>
{selectedIntegration ? t("common.update") : t("environments.integrations.slack.link_channel")}
{selectedIntegration ? t("common.update") : t("workspace.integrations.slack.link_channel")}
</Button>
</DialogFooter>
</form>

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