From 666a79044fec6d445c43ae37306f1cc422e23d43 Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Fri, 12 Dec 2025 05:05:25 +0100 Subject: [PATCH 01/24] fix: skip instance ID in license check during E2E tests (#6968) Co-authored-by: pandeymangg --- .github/workflows/e2e.yml | 4 ++-- .../modules/ee/license-check/lib/license.ts | 23 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ebdfa42909..ada2c86f84 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -114,7 +114,7 @@ jobs: - name: Start MinIO Server run: | set -euo pipefail - + # Start MinIO server in background docker run -d \ --name minio-server \ @@ -124,7 +124,7 @@ jobs: -e MINIO_ROOT_PASSWORD=devminio123 \ minio/minio:RELEASE.2025-09-07T16-13-09Z \ server /data --console-address :9001 - + echo "MinIO server started" - name: Wait for MinIO and create S3 bucket diff --git a/apps/web/modules/ee/license-check/lib/license.ts b/apps/web/modules/ee/license-check/lib/license.ts index 393f2d5f76..cdd14b2034 100644 --- a/apps/web/modules/ee/license-check/lib/license.ts +++ b/apps/web/modules/ee/license-check/lib/license.ts @@ -7,6 +7,7 @@ import { createCacheKey } from "@formbricks/cache"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { cache } from "@/lib/cache"; +import { E2E_TESTING } from "@/lib/constants"; import { env } from "@/lib/env"; import { hashString } from "@/lib/hash-string"; import { getInstanceId } from "@/lib/instance"; @@ -262,7 +263,9 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise controller.abort(), CONFIG.API.TIMEOUT_MS); + const payload: Record = { + licenseKey: env.ENTERPRISE_LICENSE_KEY, + usage: { responseCount }, + }; + + if (instanceId) { + payload.instanceId = instanceId; + } + const res = await fetch(CONFIG.API.ENDPOINT, { - body: JSON.stringify({ - licenseKey: env.ENTERPRISE_LICENSE_KEY, - usage: { responseCount }, - instanceId, - }), + body: JSON.stringify(payload), headers: { "Content-Type": "application/json" }, method: "POST", agent, From 2d7b99ba262bc6f547c5c4e37f77e1ab26318c1a Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Thu, 11 Dec 2025 21:01:48 -0800 Subject: [PATCH 02/24] feat: allow team admins to invite members to their own teams (#6891) Co-authored-by: pandeymangg --- .../components/organization-breadcrumb.tsx | 2 +- .../components/OrganizationSettingsNavbar.tsx | 2 +- apps/web/i18n.lock | 10 +- apps/web/locales/de-DE.json | 10 +- apps/web/locales/en-US.json | 13 +- apps/web/locales/es-ES.json | 10 +- apps/web/locales/fr-FR.json | 10 +- apps/web/locales/ja-JP.json | 10 +- apps/web/locales/nl-NL.json | 10 +- apps/web/locales/pt-BR.json | 10 +- apps/web/locales/pt-PT.json | 10 +- apps/web/locales/ro-RO.json | 10 +- apps/web/locales/sv-SE.json | 8 +- apps/web/locales/zh-Hans-CN.json | 10 +- apps/web/locales/zh-Hant-TW.json | 10 +- .../auth/signup/lib/__tests__/team.test.ts | 66 ++++++- apps/web/modules/auth/signup/lib/team.ts | 26 +-- .../components/add-member-role.tsx | 2 +- apps/web/modules/ee/teams/lib/roles.test.ts | 50 +++++- apps/web/modules/ee/teams/lib/roles.ts | 28 +++ .../project-teams/components/access-table.tsx | 2 +- .../project-teams/components/access-view.tsx | 5 +- .../project-teams/components/manage-team.tsx | 21 +-- .../modules/ee/teams/project-teams/page.tsx | 6 +- .../team-settings/team-settings-modal.tsx | 167 +++++++++++------- .../organization/settings/teams/actions.ts | 90 ++++++++-- .../edit-memberships/organization-actions.tsx | 27 ++- .../invite-member/individual-invite-tab.tsx | 102 +++++++---- .../invite-member/invite-member-modal.tsx | 38 ++-- .../teams/components/members-view.tsx | 7 + .../organization/settings/teams/page.tsx | 13 +- .../link/components/link-survey-wrapper.tsx | 4 +- .../ui/components/client-logo/index.tsx | 7 +- .../modules/ui/components/select/index.tsx | 2 +- apps/web/playwright/organization.spec.ts | 6 +- pnpm-lock.yaml | 1 + 36 files changed, 588 insertions(+), 217 deletions(-) diff --git a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx index dd7fd851bb..64cffa3d19 100644 --- a/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/components/organization-breadcrumb.tsx @@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({ }, { id: "teams", - label: t("common.teams"), + label: t("common.members_and_teams"), href: `/environments/${currentEnvironmentId}/settings/teams`, }, { diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx index 65a8ca0429..f43b9014ad 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar.tsx @@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({ }, { id: "teams", - label: t("common.teams"), + label: t("common.members_and_teams"), href: `/environments/${environmentId}/settings/teams`, current: pathname?.includes("/teams"), }, diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index e98cff6619..46a9532cff 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -234,6 +234,7 @@ checksums: common/maximum: 4c07541dd1f093775bdc61b559cca6c8 common/member: 1606dc30b369856b9dba1fe9aec425d2 common/members: 0932e80cba1e3e0a7f52bb67ff31da32 + common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08 common/membership_not_found: 7ac63584af23396aace9992ad919ffd4 common/metadata: 695d4f7da261ba76e3be4de495491028 common/minimum: d9759235086d0169928b3c1401115e22 @@ -313,6 +314,7 @@ checksums: common/read_docs: 426ba960bfedf186a878b7467867f9d2 common/recipients: f90e7f266be3f5a724858f21a9fd855e common/remove: dba2fe5fe9f83f8078c687f28cba4b52 + common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6 common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069 common/report_survey: 147dd05db52e35f5d1f837460fb720f5 common/request_pricing: 58eb24af4f098632709cb7482b70a1cb @@ -380,7 +382,8 @@ checksums: common/team_access: 45c6232c71b760eaa33b932dabab4c1c common/team_id: 134e32d6f7184577a46b2fd83e85e532 common/team_name: 549d949de4b9adad4afd6427a60a329e - common/teams: a2fbdec69342366a2b6033d119aa279a + common/team_role: 66db395781aef64ef3791417b3b67c0b + common/teams: b63448c05270497973ac4407047dae02 common/teams_not_found: 02f333a64a83c1c014d8900ec9666345 common/text: 4ddccc1974775ed7357f9beaf9361cec common/time: b504a03d52e8001bfdc5cb6205364f42 @@ -840,7 +843,6 @@ checksums: environments/project/tags/tags_merged: 544471de666f93fbb0ab600321d1e553 environments/project/teams/manage_teams: d7b5f26335cea450c333832adbe0b6ad environments/project/teams/no_teams_found: fb6680d4b5b73731697b100713afb50d - environments/project/teams/only_organization_owners_and_managers_can_manage_teams: 179056fade669d34f63fb1ee965b8024 environments/project/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b environments/project/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02 environments/project/teams/team_settings_description: da32d77993f5c5c7547cdf3e1d3fc7d5 @@ -1086,13 +1088,17 @@ checksums: environments/settings/teams/manage_team: 4c52e636cfd1451a08179fb7a68042ab environments/settings/teams/manage_team_disabled: 2aaa0557b403a5bc657ec9e8b19ac5ac environments/settings/teams/manager_role_description: 39846863fa85ff8b1c6e4f354eb5018f + environments/settings/teams/member: 1606dc30b369856b9dba1fe9aec425d2 environments/settings/teams/member_role_description: 1c5deaece65798b74cc0d34525506c18 environments/settings/teams/member_role_info_message: 0a276eef3c3b907d6f396ebfdc693b12 + environments/settings/teams/organization_role: 979b75fcc3696952e5922d659c839c10 environments/settings/teams/owner_role_description: 8f577e6f9d1368fed4eba5a91ffc8cbf environments/settings/teams/please_fill_all_member_fields: 60e38d9906ec9a02a44d16c736bd9fe9 environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87 environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270 environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320 + environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75 + environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb environments/settings/teams/team_created_successfully: 45f83048fcabf466551144858a761eca environments/settings/teams/team_deleted_successfully: 972c86b0abe87f229f7bf1a691c0a253 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index f6f5f6f022..53a271ecfd 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -261,6 +261,7 @@ "maximum": "Maximal", "member": "Mitglied", "members": "Mitglieder", + "members_and_teams": "Mitglieder & Teams", "membership_not_found": "Mitgliedschaft nicht gefunden", "metadata": "Metadaten", "minimum": "Minimum", @@ -340,6 +341,7 @@ "read_docs": "Dokumentation lesen", "recipients": "Empfänger", "remove": "Entfernen", + "remove_from_team": "Aus Team entfernen", "reorder_and_hide_columns": "Spalten neu anordnen und ausblenden", "report_survey": "Umfrage melden", "request_pricing": "Preise anfragen", @@ -407,7 +409,8 @@ "team_access": "Teamzugriff", "team_id": "Team-ID", "team_name": "Teamname", - "teams": "Zugriffskontrolle", + "team_role": "Team-Rolle", + "teams": "Teams", "teams_not_found": "Teams nicht gefunden", "text": "Text", "time": "Zeit", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Teams verwalten", "no_teams_found": "Keine Teams gefunden", - "only_organization_owners_and_managers_can_manage_teams": "Nur Organisationsinhaber und -manager können Teams verwalten.", "permission": "Berechtigung", "team_name": "Teamname", "team_settings_description": "Teams und ihre Mitglieder können auf dieses Projekt und seine Umfragen zugreifen. Organisationsbesitzer und Manager können diesen Zugriff gewähren." @@ -1167,13 +1169,17 @@ "manage_team": "Team verwalten", "manage_team_disabled": "Nur Organisationsbesitzer, Manager und Team-Admins können Teams verwalten.", "manager_role_description": "Manager können auf alle Projekte zugreifen und Mitglieder hinzufügen und entfernen.", + "member": "Mitglied", "member_role_description": "Mitglieder können in ausgewählten Projekten arbeiten.", "member_role_info_message": "Um neuen Mitgliedern Zugriff auf ein Projekt zu geben, füge sie bitte unten einem Team hinzu. Mit Teams kannst du steuern, wer auf welches Projekt zugreifen kann.", + "organization_role": "Organisationsrolle", "owner_role_description": "Besitzer haben die volle Kontrolle über die Organisation.", "please_fill_all_member_fields": "Bitte fülle alle Felder aus, um ein neues Mitglied hinzuzufügen.", "please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.", "read": "Lesen", "read_write": "Lesen & Schreiben", + "select_member": "Mitglied auswählen", + "select_project": "Projekt auswählen", "team_admin": "Team-Admin", "team_created_successfully": "Team erfolgreich erstellt.", "team_deleted_successfully": "Team erfolgreich gelöscht.", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index ee5fa83ee8..5893d65434 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -261,6 +261,7 @@ "maximum": "Maximum", "member": "Member", "members": "Members", + "members_and_teams": "Members & Teams", "membership_not_found": "Membership not found", "metadata": "Metadata", "minimum": "Minimum", @@ -337,9 +338,10 @@ "quota": "Quota", "quotas": "Quotas", "quotas_description": "Limit the amount of responses you receive from participants who meet certain criteria.", - "read_docs": "Read Docs", + "read_docs": "Read docs", "recipients": "Recipients", "remove": "Remove", + "remove_from_team": "Remove from team", "reorder_and_hide_columns": "Reorder and hide columns", "report_survey": "Report Survey", "request_pricing": "Request Pricing", @@ -349,7 +351,6 @@ "responses": "Responses", "restart": "Restart", "role": "Role", - "role_organization": "Role (Organization)", "saas": "SaaS", "sales": "Sales", "save": "Save", @@ -407,7 +408,8 @@ "team_access": "Team Access", "team_id": "Team ID", "team_name": "Team name", - "teams": "Access Control", + "team_role": "Team role", + "teams": "Teams", "teams_not_found": "Teams not found", "text": "Text", "time": "Time", @@ -903,7 +905,6 @@ "teams": { "manage_teams": "Manage teams", "no_teams_found": "No teams found", - "only_organization_owners_and_managers_can_manage_teams": "Only organization owners and managers can manage teams.", "permission": "Permission", "team_name": "Team Name", "team_settings_description": "See which teams can access this project." @@ -1167,13 +1168,17 @@ "manage_team": "Manage team", "manage_team_disabled": "Only organization owners, managers and team admins can manage teams.", "manager_role_description": "Managers can access all projects and add and remove members.", + "member": "Member", "member_role_description": "Members can work in selected projects.", "member_role_info_message": "To give new members access to a project, please add them to a Team below. With Teams you can manage who has access to which project.", + "organization_role": "Organization role", "owner_role_description": "Owners have full control over the organization.", "please_fill_all_member_fields": "Please fill all the fields to add a new member.", "please_fill_all_project_fields": "Please fill all the fields to add a new project.", "read": "Read", "read_write": "Read & Write", + "select_member": "Select member", + "select_project": "Select project", "team_admin": "Team Admin", "team_created_successfully": "Team created successfully.", "team_deleted_successfully": "Team deleted successfully.", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 42544916e9..2b49aa2a5a 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -261,6 +261,7 @@ "maximum": "Máximo", "member": "Miembro", "members": "Miembros", + "members_and_teams": "Miembros y equipos", "membership_not_found": "Membresía no encontrada", "metadata": "Metadatos", "minimum": "Mínimo", @@ -340,6 +341,7 @@ "read_docs": "Leer documentación", "recipients": "Destinatarios", "remove": "Eliminar", + "remove_from_team": "Eliminar del equipo", "reorder_and_hide_columns": "Reordenar y ocultar columnas", "report_survey": "Reportar encuesta", "request_pricing": "Solicitar precios", @@ -407,7 +409,8 @@ "team_access": "Acceso de equipo", "team_id": "ID de equipo", "team_name": "Nombre del equipo", - "teams": "Control de acceso", + "team_role": "Rol del equipo", + "teams": "Equipos", "teams_not_found": "Equipos no encontrados", "text": "Texto", "time": "Hora", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Gestionar equipos", "no_teams_found": "No se han encontrado equipos", - "only_organization_owners_and_managers_can_manage_teams": "Solo los propietarios y gestores de la organización pueden gestionar equipos.", "permission": "Permiso", "team_name": "Nombre del equipo", "team_settings_description": "Consulta qué equipos pueden acceder a este proyecto." @@ -1167,13 +1169,17 @@ "manage_team": "Gestionar equipo", "manage_team_disabled": "Solo los propietarios de la organización, gestores y administradores de equipo pueden gestionar equipos.", "manager_role_description": "Los gestores pueden acceder a todos los proyectos y añadir y eliminar miembros.", + "member": "Miembro", "member_role_description": "Los miembros pueden trabajar en proyectos seleccionados.", "member_role_info_message": "Para dar a los nuevos miembros acceso a un proyecto, por favor añádelos a un equipo a continuación. Con los equipos puedes gestionar quién tiene acceso a qué proyecto.", + "organization_role": "Rol en la organización", "owner_role_description": "Los propietarios tienen control total sobre la organización.", "please_fill_all_member_fields": "Por favor, rellena todos los campos para añadir un nuevo miembro.", "please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.", "read": "Lectura", "read_write": "Lectura y escritura", + "select_member": "Seleccionar miembro", + "select_project": "Seleccionar proyecto", "team_admin": "Administrador de equipo", "team_created_successfully": "Equipo creado con éxito.", "team_deleted_successfully": "Equipo eliminado correctamente.", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 46cd55662e..8fef157bb1 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -261,6 +261,7 @@ "maximum": "Max", "member": "Membre", "members": "Membres", + "members_and_teams": "Membres & Équipes", "membership_not_found": "Abonnement non trouvé", "metadata": "Métadonnées", "minimum": "Min", @@ -340,6 +341,7 @@ "read_docs": "Lire les documents", "recipients": "Destinataires", "remove": "Retirer", + "remove_from_team": "Retirer de l'équipe", "reorder_and_hide_columns": "Réorganiser et masquer des colonnes", "report_survey": "Rapport d'enquête", "request_pricing": "Connaître le tarif", @@ -407,7 +409,8 @@ "team_access": "Accès", "team_id": "Identifiant de l'équipe", "team_name": "Nom de l'équipe", - "teams": "Contrôle d'accès", + "team_role": "Rôle dans l'équipe", + "teams": "Équipes", "teams_not_found": "Équipes non trouvées", "text": "Texte", "time": "Temps", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Gérer les équipes", "no_teams_found": "Aucune équipe trouvée", - "only_organization_owners_and_managers_can_manage_teams": "Seuls les propriétaires et les gestionnaires de l'organisation peuvent gérer les équipes.", "permission": "Permission", "team_name": "Nom de l'équipe", "team_settings_description": "Vous pouvez consulter la liste des équipes qui ont accès à ce projet." @@ -1167,13 +1169,17 @@ "manage_team": "Gérer l'équipe", "manage_team_disabled": "Seuls les propriétaires de l'organisation, les gestionnaires et les administrateurs d'équipe peuvent gérer les équipes.", "manager_role_description": "Les gestionnaires peuvent accéder à tous les projets et ajouter et supprimer des membres.", + "member": "Membre", "member_role_description": "Les membres peuvent travailler sur des projets sélectionnés.", "member_role_info_message": "Pour donner accès à un projet aux nouveaux membres, veuillez les ajouter à une équipe ci-dessous. Avec les équipes, vous pouvez gérer qui a accès à quel projet.", + "organization_role": "Rôle dans l'organisation", "owner_role_description": "Les propriétaires ont un contrôle total sur l'organisation.", "please_fill_all_member_fields": "Veuillez remplir tous les champs pour ajouter un nouveau membre.", "please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.", "read": "Lire", "read_write": "Lire et Écrire", + "select_member": "Sélectionner membre", + "select_project": "Sélectionner projet", "team_admin": "Administrateur d'équipe", "team_created_successfully": "Équipe créée avec succès.", "team_deleted_successfully": "Équipe supprimée avec succès.", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index ee26fb6f0d..3be1eae10d 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -261,6 +261,7 @@ "maximum": "最大", "member": "メンバー", "members": "メンバー", + "members_and_teams": "メンバー&チーム", "membership_not_found": "メンバーシップが見つかりません", "metadata": "メタデータ", "minimum": "最小", @@ -340,6 +341,7 @@ "read_docs": "ドキュメントを読む", "recipients": "受信者", "remove": "削除", + "remove_from_team": "チームから削除", "reorder_and_hide_columns": "列の並び替えと非表示", "report_survey": "フォームを報告", "request_pricing": "料金を問い合わせる", @@ -407,7 +409,8 @@ "team_access": "チームアクセス", "team_id": "チームID", "team_name": "チーム名", - "teams": "アクセス制御", + "team_role": "チームの役割", + "teams": "チーム", "teams_not_found": "チームが見つかりません", "text": "テキスト", "time": "時間", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "チームを管理", "no_teams_found": "チームが見つかりません", - "only_organization_owners_and_managers_can_manage_teams": "組織のオーナーまたは管理者のみがチームを管理できます。", "permission": "権限", "team_name": "チーム名", "team_settings_description": "このプロジェクトにアクセスできるチームを確認します。" @@ -1167,13 +1169,17 @@ "manage_team": "チームを管理", "manage_team_disabled": "組織のオーナー、管理者、チーム管理者のみがチームを管理できます。", "manager_role_description": "管理者はすべてのプロジェクトにアクセスでき、メンバーを追加および削除できます。", + "member": "メンバー", "member_role_description": "メンバーは選択されたプロジェクトで作業できます。", "member_role_info_message": "新しいメンバーにプロジェクトへのアクセス権を付与するには、以下のチームに追加してください。チームを使用すると、誰がどのプロジェクトにアクセスできるかを管理できます。", + "organization_role": "組織の役割", "owner_role_description": "オーナーは組織を完全に制御できます。", "please_fill_all_member_fields": "新しいメンバーを追加するには、すべてのフィールドを記入してください。", "please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。", "read": "読み取り", "read_write": "読み書き", + "select_member": "メンバーを選択", + "select_project": "プロジェクトを選択", "team_admin": "チーム管理者", "team_created_successfully": "チームを正常に作成しました。", "team_deleted_successfully": "チームを正常に削除しました。", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 5f9d7b36e8..eeb63ca0f5 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -261,6 +261,7 @@ "maximum": "Maximaal", "member": "Lid", "members": "Leden", + "members_and_teams": "Leden & teams", "membership_not_found": "Lidmaatschap niet gevonden", "metadata": "Metagegevens", "minimum": "Minimum", @@ -340,6 +341,7 @@ "read_docs": "Lees Documenten", "recipients": "Ontvangers", "remove": "Verwijderen", + "remove_from_team": "Verwijderen uit team", "reorder_and_hide_columns": "Kolommen opnieuw rangschikken en verbergen", "report_survey": "Verslag enquête", "request_pricing": "Vraag prijzen aan", @@ -407,7 +409,8 @@ "team_access": "Teamtoegang", "team_id": "Team-ID", "team_name": "Teamnaam", - "teams": "Toegangscontrole", + "team_role": "Teamrol", + "teams": "Teams", "teams_not_found": "Teams niet gevonden", "text": "Tekst", "time": "Tijd", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Beheer teams", "no_teams_found": "Geen teams gevonden", - "only_organization_owners_and_managers_can_manage_teams": "Alleen eigenaren en managers van organisaties kunnen teams beheren.", "permission": "Toestemming", "team_name": "Teamnaam", "team_settings_description": "Bekijk welke teams toegang hebben tot dit project." @@ -1167,13 +1169,17 @@ "manage_team": "Beheer team", "manage_team_disabled": "Alleen organisatie-eigenaren, managers en teambeheerders kunnen teams beheren.", "manager_role_description": "Managers hebben toegang tot alle projecten en kunnen leden toevoegen en verwijderen.", + "member": "Lid", "member_role_description": "Leden kunnen in geselecteerde projecten werken.", "member_role_info_message": "Om nieuwe leden toegang te geven tot een project, voegt u ze hieronder toe aan een team. Met Teams kun je beheren wie toegang heeft tot welk project.", + "organization_role": "Organisatierol", "owner_role_description": "Eigenaars hebben volledige controle over de organisatie.", "please_fill_all_member_fields": "Vul alle velden in om een ​​nieuw lid toe te voegen.", "please_fill_all_project_fields": "Vul alle velden in om een ​​nieuw project toe te voegen.", "read": "Lezen", "read_write": "Lezen en schrijven", + "select_member": "Selecteer lid", + "select_project": "Selecteer project", "team_admin": "Teambeheerder", "team_created_successfully": "Team succesvol aangemaakt.", "team_deleted_successfully": "Team succesvol verwijderd.", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 89da514535..effc6967ed 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -261,6 +261,7 @@ "maximum": "Máximo", "member": "Membros", "members": "Membros", + "members_and_teams": "Membros e equipes", "membership_not_found": "Assinatura não encontrada", "metadata": "metadados", "minimum": "Mínimo", @@ -340,6 +341,7 @@ "read_docs": "Ler Documentação", "recipients": "Destinatários", "remove": "remover", + "remove_from_team": "Remover da equipe", "reorder_and_hide_columns": "Reordenar e ocultar colunas", "report_survey": "Relatório de Pesquisa", "request_pricing": "Solicitar Preços", @@ -407,7 +409,8 @@ "team_access": "Acesso da equipe", "team_id": "ID da Equipe", "team_name": "Nome da equipe", - "teams": "Controle de Acesso", + "team_role": "Função na equipe", + "teams": "Equipes", "teams_not_found": "Equipes não encontradas", "text": "Texto", "time": "tempo", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Gerenciar Equipes", "no_teams_found": "Nenhuma equipe encontrada", - "only_organization_owners_and_managers_can_manage_teams": "Apenas proprietários e gerentes da organização podem gerenciar equipes.", "permission": "Permissão", "team_name": "Nome da equipe", "team_settings_description": "As equipes e seus membros podem acessar este projeto e suas pesquisas. Proprietários e gerentes da organização podem conceder esse acesso." @@ -1167,13 +1169,17 @@ "manage_team": "Gerenciar equipe", "manage_team_disabled": "Apenas proprietários da organização, gerentes e administradores da equipe podem gerenciar equipes.", "manager_role_description": "Os gerentes podem acessar todos os projetos e adicionar e remover membros.", + "member": "Membro", "member_role_description": "Os membros podem trabalhar em projetos selecionados.", "member_role_info_message": "Para dar acesso a novos membros a um projeto, por favor, adicione-os a uma equipe abaixo. Com equipes, você pode gerenciar quem tem acesso a qual projeto.", + "organization_role": "Função na organização", "owner_role_description": "Os proprietários têm controle total sobre a organização.", "please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.", "please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.", "read": "Leitura", "read_write": "Leitura & Escrita", + "select_member": "Selecionar membro", + "select_project": "Selecionar projeto", "team_admin": "Administrador da equipe", "team_created_successfully": "Equipe criada com sucesso.", "team_deleted_successfully": "Equipe excluída com sucesso.", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 466315bc32..36648ae707 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -261,6 +261,7 @@ "maximum": "Máximo", "member": "Membro", "members": "Membros", + "members_and_teams": "Membros e equipas", "membership_not_found": "Associação não encontrada", "metadata": "Metadados", "minimum": "Mínimo", @@ -340,6 +341,7 @@ "read_docs": "Ler Documentos", "recipients": "Destinatários", "remove": "Remover", + "remove_from_team": "Remover da equipa", "reorder_and_hide_columns": "Reordenar e ocultar colunas", "report_survey": "Relatório de Inquérito", "request_pricing": "Pedido de Preços", @@ -407,7 +409,8 @@ "team_access": "Acesso da Equipa", "team_id": "ID da Equipa", "team_name": "Nome da equipa", - "teams": "Controlo de Acesso", + "team_role": "Função na equipa", + "teams": "Equipas", "teams_not_found": "Equipas não encontradas", "text": "Texto", "time": "Tempo", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Gerir equipas", "no_teams_found": "Nenhuma equipa encontrada", - "only_organization_owners_and_managers_can_manage_teams": "Apenas os proprietários e gestores da organização podem gerir equipas.", "permission": "Permissão", "team_name": "Nome da Equipa", "team_settings_description": "Veja quais equipas podem aceder a este projeto." @@ -1167,13 +1169,17 @@ "manage_team": "Gerir equipa", "manage_team_disabled": "Apenas os proprietários da organização, gestores e administradores de equipa podem gerir equipas.", "manager_role_description": "Os gestores podem aceder a todos os projetos e adicionar e remover membros.", + "member": "Membro", "member_role_description": "Os membros podem trabalhar em projetos selecionados.", "member_role_info_message": "Adicione os membros que deseja a uma Equipa abaixo. Nesta secção, pode gerir quem tem acesso a cada projeto.", + "organization_role": "Função na organização", "owner_role_description": "Os proprietários têm controlo total sobre a organização.", "please_fill_all_member_fields": "Por favor, preencha todos os campos para adicionar um novo membro.", "please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.", "read": "Ler", "read_write": "Ler e Escrever", + "select_member": "Selecionar membro", + "select_project": "Selecionar projeto", "team_admin": "Administrador da Equipa", "team_created_successfully": "Equipa criada com sucesso.", "team_deleted_successfully": "Equipa eliminada com sucesso.", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 7faa1e952b..a2e611096e 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -261,6 +261,7 @@ "maximum": "Maximum", "member": "Membru", "members": "Membri", + "members_and_teams": "Membri și echipe", "membership_not_found": "Apartenența nu a fost găsită", "metadata": "Metadate", "minimum": "Minim", @@ -340,6 +341,7 @@ "read_docs": "Citește documentația", "recipients": "Destinatari", "remove": "Șterge", + "remove_from_team": "Elimină din echipă", "reorder_and_hide_columns": "Reordonați și ascundeți coloanele", "report_survey": "Raportează chestionarul", "request_pricing": "Solicită Prețuri", @@ -407,7 +409,8 @@ "team_access": "Acces echipă", "team_id": "ID echipă", "team_name": "Nume echipă", - "teams": "Control acces", + "team_role": "Rol în echipă", + "teams": "Echipe", "teams_not_found": "Echipele nu au fost găsite", "text": "Text", "time": "Timp", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Gestionați echipele", "no_teams_found": "Nicio echipă găsită", - "only_organization_owners_and_managers_can_manage_teams": "Doar proprietarii de organizație și managerii pot gestiona echipele.", "permission": "Permisiune", "team_name": "Nume echipă", "team_settings_description": "Vezi care echipe pot accesa acest proiect." @@ -1167,13 +1169,17 @@ "manage_team": "Gestionați echipa", "manage_team_disabled": "Doar proprietarii de organizații, managerii și administratorii de echipă pot gestiona echipele.", "manager_role_description": "Managerii pot accesa toate proiectele și pot adăuga sau elimina membri.", + "member": "Membru", "member_role_description": "Membrii pot lucra în proiectele selectate.", "member_role_info_message": "Pentru a oferi membrilor noi acces la un proiect, vă rugăm să-i adăugați la o Echipă mai jos. Cu Echipe puteți gestiona cine are acces la ce proiect.", + "organization_role": "Rol în organizație", "owner_role_description": "Proprietarii au control total asupra organizației.", "please_fill_all_member_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un nou membru.", "please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.", "read": "Citește", "read_write": "Citire & Scriere", + "select_member": "Selectează membrul", + "select_project": "Selectează proiectul", "team_admin": "Administrator Echipe", "team_created_successfully": "Echipă creată cu succes", "team_deleted_successfully": "Echipă ștearsă cu succes.", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index d46be0c3fb..268221f15f 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -261,6 +261,7 @@ "maximum": "Maximum", "member": "Medlem", "members": "Medlemmar", + "members_and_teams": "Medlemmar och team", "membership_not_found": "Medlemskap hittades inte", "metadata": "Metadata", "minimum": "Minimum", @@ -340,6 +341,7 @@ "read_docs": "Läs dokumentation", "recipients": "Mottagare", "remove": "Ta bort", + "remove_from_team": "Ta bort från teamet", "reorder_and_hide_columns": "Ordna om och dölj kolumner", "report_survey": "Rapportera enkät", "request_pricing": "Begär prissättning", @@ -407,6 +409,7 @@ "team_access": "Teamåtkomst", "team_id": "Team-ID", "team_name": "Teamnamn", + "team_role": "Teamroll", "teams": "Åtkomstkontroll", "teams_not_found": "Team hittades inte", "text": "Text", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "Hantera team", "no_teams_found": "Inga team hittades", - "only_organization_owners_and_managers_can_manage_teams": "Endast organisationsägare och administratörer kan hantera team.", "permission": "Behörighet", "team_name": "Teamnamn", "team_settings_description": "Se vilka team som kan komma åt detta projekt." @@ -1167,13 +1169,17 @@ "manage_team": "Hantera team", "manage_team_disabled": "Endast organisationsägare, administratörer och teamadministratörer kan hantera team.", "manager_role_description": "Administratörer kan komma åt alla projekt och lägga till och ta bort medlemmar.", + "member": "Medlem", "member_role_description": "Medlemmar kan arbeta i valda projekt.", "member_role_info_message": "För att ge nya medlemmar åtkomst till ett projekt, vänligen lägg till dem i ett team nedan. Med team kan du hantera vem som har åtkomst till vilket projekt.", + "organization_role": "Organisationsroll", "owner_role_description": "Ägare har full kontroll över organisationen.", "please_fill_all_member_fields": "Vänligen fyll i alla fält för att lägga till en ny medlem.", "please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.", "read": "Läs", "read_write": "Läs och skriv", + "select_member": "Välj medlem", + "select_project": "Välj projekt", "team_admin": "Teamadministratör", "team_created_successfully": "Team skapat.", "team_deleted_successfully": "Team borttaget.", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index f8ea6ed123..eaa8632fcf 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -261,6 +261,7 @@ "maximum": "最大值", "member": "成员", "members": "成员", + "members_and_teams": "成员和团队", "membership_not_found": "未找到会员资格", "metadata": "元数据", "minimum": "最低", @@ -340,6 +341,7 @@ "read_docs": "阅读 文档", "recipients": "收件人", "remove": "移除", + "remove_from_team": "从团队中移除", "reorder_and_hide_columns": "重新排序和隐藏列", "report_survey": "报告调查", "request_pricing": "请求 定价", @@ -407,7 +409,8 @@ "team_access": "团队 访问", "team_id": "团队 ID", "team_name": "团队 名称", - "teams": "访问控制", + "team_role": "团队角色", + "teams": "团队", "teams_not_found": "未找到 团队", "text": "文本", "time": "时间", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "管理 团队", "no_teams_found": "未找到 团队", - "only_organization_owners_and_managers_can_manage_teams": "只有 组织 拥有者 和 经理 可以 管理 团队。", "permission": "权限", "team_name": "团队名称", "team_settings_description": "查看 哪些 团队 可以 访问 该 项目。" @@ -1167,13 +1169,17 @@ "manage_team": "管理团队", "manage_team_disabled": "只有 组织 拥有者、经理 和 团队 管理员 可以 管理 团队。", "manager_role_description": "经理 可以 访问 所有 项目 并 添加 移除 成员。", + "member": "成员", "member_role_description": "成员 可以 在 选定 项目 中 工作。", "member_role_info_message": "要 给 新 成员 访问 项目 ,请 将 他们 添加 到 下方 的 团队 。通过 团队 你 可以 管理 谁 可以 访问 哪个 项目 。", + "organization_role": "组织角色", "owner_role_description": "所有者拥有对组织的完全控制权。", "please_fill_all_member_fields": "请 填写 所有 字段 以 添加 新 成员。", "please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。", "read": "阅读", "read_write": "读 & 写", + "select_member": "选择成员", + "select_project": "选择项目", "team_admin": "团队管理员", "team_created_successfully": "团队 创建 成功", "team_deleted_successfully": "团队 删除 成功", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index 19fbd6e996..ebdbbf045c 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -261,6 +261,7 @@ "maximum": "最大值", "member": "成員", "members": "成員", + "members_and_teams": "成員與團隊", "membership_not_found": "找不到成員資格", "metadata": "元數據", "minimum": "最小值", @@ -340,6 +341,7 @@ "read_docs": "閱讀文件", "recipients": "收件者", "remove": "移除", + "remove_from_team": "從團隊中移除", "reorder_and_hide_columns": "重新排序和隱藏欄位", "report_survey": "報告問卷", "request_pricing": "請求定價", @@ -407,7 +409,8 @@ "team_access": "團隊存取權限", "team_id": "團隊 ID", "team_name": "團隊名稱", - "teams": "存取控制", + "team_role": "團隊角色", + "teams": "團隊", "teams_not_found": "找不到團隊", "text": "文字", "time": "時間", @@ -903,7 +906,6 @@ "teams": { "manage_teams": "管理團隊", "no_teams_found": "找不到團隊", - "only_organization_owners_and_managers_can_manage_teams": "只有組織擁有者和管理員才能管理團隊。", "permission": "權限", "team_name": "團隊名稱", "team_settings_description": "查看哪些團隊可以存取此專案。" @@ -1167,13 +1169,17 @@ "manage_team": "管理團隊", "manage_team_disabled": "只有組織擁有者、管理員和團隊管理員才能管理團隊。", "manager_role_description": "管理員可以存取所有專案,並新增和移除成員。", + "member": "成員", "member_role_description": "成員可以在選定的專案中工作。", "member_role_info_message": "若要授予新成員存取專案的權限,請將他們新增至下方的團隊。藉由團隊,您可以管理誰可以存取哪些專案。", + "organization_role": "組織角色", "owner_role_description": "擁有者對組織具有完全控制權。", "please_fill_all_member_fields": "請填寫所有欄位以新增新成員。", "please_fill_all_project_fields": "請填寫所有欄位以新增新專案。", "read": "讀取", "read_write": "讀取和寫入", + "select_member": "選擇成員", + "select_project": "選擇專案", "team_admin": "團隊管理員", "team_created_successfully": "團隊已成功建立。", "team_deleted_successfully": "團隊已成功刪除。", diff --git a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts index 026c1e4094..d8821e31fe 100644 --- a/apps/web/modules/auth/signup/lib/__tests__/team.test.ts +++ b/apps/web/modules/auth/signup/lib/__tests__/team.test.ts @@ -3,7 +3,7 @@ import { OrganizationRole } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { CreateMembershipInvite } from "@/modules/auth/signup/types/invites"; -import { createTeamMembership } from "../team"; +import { createTeamMembership, getTeamProjectIds } from "../team"; // Setup all mocks const setupMocks = () => { @@ -31,6 +31,7 @@ const setupMocks = () => { vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), + warn: vi.fn(), }, })); @@ -55,7 +56,7 @@ describe("Team Management", () => { describe("createTeamMembership", () => { describe("when user is an admin", () => { test("creates a team membership with admin role", async () => { - vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any); vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER); await createTeamMembership(MOCK_INVITE, MOCK_IDS.userId); @@ -90,7 +91,7 @@ describe("Team Management", () => { role: "member" as OrganizationRole, }; - vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any); vi.mocked(prisma.teamUser.create).mockResolvedValue({ ...MOCK_TEAM_USER, role: "contributor", @@ -110,11 +111,68 @@ describe("Team Management", () => { describe("error handling", () => { test("throws error when database operation fails", async () => { - vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM); + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any); vi.mocked(prisma.teamUser.create).mockRejectedValue(new Error("Database error")); await expect(createTeamMembership(MOCK_INVITE, MOCK_IDS.userId)).rejects.toThrow("Database error"); }); }); + + describe("when team does not exist", () => { + test("skips membership creation and continues to next team", async () => { + const inviteWithMultipleTeams: CreateMembershipInvite = { + ...MOCK_INVITE, + teamIds: ["non-existent-team", MOCK_IDS.teamId], + }; + + vi.mocked(prisma.team.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(MOCK_TEAM as unknown as any); + vi.mocked(prisma.teamUser.create).mockResolvedValue(MOCK_TEAM_USER); + + await createTeamMembership(inviteWithMultipleTeams, MOCK_IDS.userId); + + expect(prisma.team.findUnique).toHaveBeenCalledTimes(2); + expect(prisma.teamUser.create).toHaveBeenCalledTimes(1); + expect(prisma.teamUser.create).toHaveBeenCalledWith({ + data: { + teamId: MOCK_IDS.teamId, + userId: MOCK_IDS.userId, + role: "admin", + }, + }); + }); + }); + }); + + describe("getTeamProjectIds", () => { + test("returns team with projectTeams when team exists", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_TEAM as unknown as any); + + const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId); + + expect(result).toEqual(MOCK_TEAM); + expect(prisma.team.findUnique).toHaveBeenCalledWith({ + where: { + id: MOCK_IDS.teamId, + organizationId: MOCK_IDS.organizationId, + }, + select: { + projectTeams: { + select: { + projectId: true, + }, + }, + }, + }); + }); + + test("returns null when team does not exist", async () => { + vi.mocked(prisma.team.findUnique).mockResolvedValue(null); + + const result = await getTeamProjectIds(MOCK_IDS.teamId, MOCK_IDS.organizationId); + + expect(result).toBeNull(); + }); }); }); diff --git a/apps/web/modules/auth/signup/lib/team.ts b/apps/web/modules/auth/signup/lib/team.ts index 1fd81fe294..7f1324a9a4 100644 --- a/apps/web/modules/auth/signup/lib/team.ts +++ b/apps/web/modules/auth/signup/lib/team.ts @@ -18,15 +18,18 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI for (const teamId of teamIds) { const team = await getTeamProjectIds(teamId, invite.organizationId); - if (team) { - await prisma.teamUser.create({ - data: { - teamId, - userId, - role: isOwnerOrManager ? "admin" : "contributor", - }, - }); + if (!team) { + logger.warn({ teamId, userId }, "Team no longer exists during invite acceptance"); + continue; } + + await prisma.teamUser.create({ + data: { + teamId, + userId, + role: isOwnerOrManager ? "admin" : "contributor", + }, + }); } } catch (error) { logger.error(error, `Error creating team membership ${invite.organizationId} ${userId}`); @@ -39,7 +42,10 @@ export const createTeamMembership = async (invite: CreateMembershipInvite, userI }; export const getTeamProjectIds = reactCache( - async (teamId: string, organizationId: string): Promise<{ projectTeams: { projectId: string }[] }> => { + async ( + teamId: string, + organizationId: string + ): Promise<{ projectTeams: { projectId: string }[] } | null> => { const team = await prisma.team.findUnique({ where: { id: teamId, @@ -55,7 +61,7 @@ export const getTeamProjectIds = reactCache( }); if (!team) { - throw new Error("Team not found"); + return null; } return team; diff --git a/apps/web/modules/ee/role-management/components/add-member-role.tsx b/apps/web/modules/ee/role-management/components/add-member-role.tsx index 2b5ed799d1..00400f3b5d 100644 --- a/apps/web/modules/ee/role-management/components/add-member-role.tsx +++ b/apps/web/modules/ee/role-management/components/add-member-role.tsx @@ -60,7 +60,7 @@ export function AddMemberRole({ name="role" render={({ field: { onChange, value } }) => (
- + { - field.onChange(val); - handleMemberSelectionChange(index, val); - }} - disabled={!isOwnerOrManager && !isTeamAdminMember} - value={member.userId}> - - - - - {memberOpts.map((option) => ( - - {option.label} - - ))} - - - {error?.message && ( - {error.message} - )} - - )} + render={({ field, fieldState: { error } }) => { + // Disable user select for existing members (can only remove or change role) + const isExistingMember = + member.userId && initialMemberIds.has(member.userId); + const isSelectDisabled = + isExistingMember || (!isOwnerOrManager && !isTeamAdminMember); + + return ( + + + {error?.message && ( + {error.message} + )} + + ); + }} /> 1 && ( - + + + )}
); @@ -360,7 +382,7 @@ export const TeamSettingsModal = ({ : t("environments.settings.teams.all_members_added") }> )} - {!isInviteDisabled && isOwnerOrManager && !isUserManagementDisabledFromUi && ( + {!isInviteDisabled && canInvite && !isUserManagementDisabledFromUi && ( @@ -153,7 +159,7 @@ export const OrganizationActions = ({ - + {t("environments.settings.general.leave_organization_title")} @@ -177,7 +186,7 @@ export const OrganizationActions = ({

)} -
diff --git a/apps/web/modules/organization/settings/teams/components/members-view.tsx b/apps/web/modules/organization/settings/teams/components/members-view.tsx index e745271966..cbae9e3e1c 100644 --- a/apps/web/modules/organization/settings/teams/components/members-view.tsx +++ b/apps/web/modules/organization/settings/teams/components/members-view.tsx @@ -5,6 +5,7 @@ import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/ import { INVITE_DISABLED, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants"; import { getTranslate } from "@/lingodotdev/server"; import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils"; +import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles"; import { getTeamsByOrganizationId } from "@/modules/ee/teams/team-list/lib/team"; import { TOrganizationTeam } from "@/modules/ee/teams/team-list/types/team"; import { EditMemberships } from "@/modules/organization/settings/teams/components/edit-memberships"; @@ -45,6 +46,10 @@ export const MembersView = async ({ const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + // Fetch admin teams if they're a team admin + const userAdminTeamIds = await getTeamsWhereUserIsAdmin(currentUserId, organization.id); + const isTeamAdminUser = userAdminTeamIds.length > 0; + let teams: TOrganizationTeam[] = []; if (isAccessControlAllowed) { @@ -69,6 +74,8 @@ export const MembersView = async ({ isMultiOrgEnabled={isMultiOrgEnabled} teams={teams} isUserManagementDisabledFromUi={isUserManagementDisabledFromUi} + isTeamAdmin={isTeamAdminUser} + userAdminTeamIds={userAdminTeamIds} /> )} diff --git a/apps/web/modules/organization/settings/teams/page.tsx b/apps/web/modules/organization/settings/teams/page.tsx index 2d534b80c9..9a5a45dea3 100644 --- a/apps/web/modules/organization/settings/teams/page.tsx +++ b/apps/web/modules/organization/settings/teams/page.tsx @@ -3,6 +3,7 @@ import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constan import { getUserManagementAccess } from "@/lib/membership/utils"; import { getTranslate } from "@/lingodotdev/server"; import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils"; +import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles"; import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view"; import { getEnvironmentAuth } from "@/modules/environments/lib/utils"; import { MembersView } from "@/modules/organization/settings/teams/components/members-view"; @@ -16,11 +17,21 @@ export const TeamsPage = async (props) => { const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId); const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan); - const hasUserManagementAccess = getUserManagementAccess( + + // Check if user has standard user management access (owner/manager) + const hasStandardUserManagementAccess = getUserManagementAccess( currentUserMembership?.role, USER_MANAGEMENT_MINIMUM_ROLE ); + // Also check if user is a team admin (they get limited user management for invites) + const userAdminTeamIds = await getTeamsWhereUserIsAdmin(session.user.id, organization.id); + const isTeamAdminUser = userAdminTeamIds.length > 0; + + // Allow user management UI if they're owner/manager OR team admin (when access control is enabled) + const hasUserManagementAccess = + hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser); + return ( diff --git a/apps/web/modules/survey/link/components/link-survey-wrapper.tsx b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx index 29e52977a8..d5043d4862 100644 --- a/apps/web/modules/survey/link/components/link-survey-wrapper.tsx +++ b/apps/web/modules/survey/link/components/link-survey-wrapper.tsx @@ -79,7 +79,9 @@ export const LinkSurveyWrapper = ({ styling={styling} onBackgroundLoaded={handleBackgroundLoaded}>
- {!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && } + {!styling.isLogoHidden && (project.logo?.url || styling.logo?.url) && ( + + )}
{isPreview && (
diff --git a/apps/web/modules/ui/components/client-logo/index.tsx b/apps/web/modules/ui/components/client-logo/index.tsx index 51f768a94a..8b84264d47 100644 --- a/apps/web/modules/ui/components/client-logo/index.tsx +++ b/apps/web/modules/ui/components/client-logo/index.tsx @@ -15,7 +15,12 @@ interface ClientLogoProps { previewSurvey?: boolean; } -export const ClientLogo = ({ environmentId, projectLogo, surveyLogo, previewSurvey = false }: ClientLogoProps) => { +export const ClientLogo = ({ + environmentId, + projectLogo, + surveyLogo, + previewSurvey = false, +}: ClientLogoProps) => { const { t } = useTranslation(); const logoToUse = surveyLogo?.url ? surveyLogo : projectLogo; diff --git a/apps/web/modules/ui/components/select/index.tsx b/apps/web/modules/ui/components/select/index.tsx index 3be9861226..b1079a0418 100644 --- a/apps/web/modules/ui/components/select/index.tsx +++ b/apps/web/modules/ui/components/select/index.tsx @@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef< diff --git a/apps/web/playwright/organization.spec.ts b/apps/web/playwright/organization.spec.ts index 18f99c2321..3d50b76613 100644 --- a/apps/web/playwright/organization.spec.ts +++ b/apps/web/playwright/organization.spec.ts @@ -20,7 +20,7 @@ test.describe("Invite, accept and remove organization member", async () => { await page.locator('[data-testid="members-loading-card"]:first-child').waitFor({ state: "hidden" }); - await page.getByRole("link", { name: "Access Control" }).click(); + await page.getByRole("link", { name: "Members & Teams" }).click(); // Add member button await expect(page.getByRole("button", { name: "Invite member" })).toBeVisible(); @@ -131,8 +131,8 @@ test.describe("Create, update and delete team", async () => { await page.waitForURL(/\/environments\/[^/]+\/settings\/general/); await page.waitForTimeout(2000); - await expect(page.getByText("Access Control")).toBeVisible(); - await page.getByText("Access Control").click(); + await expect(page.getByText("Members & Teams")).toBeVisible(); + await page.getByText("Members & Teams").click(); await page.waitForURL(/\/environments\/[^/]+\/settings\/teams/); await expect(page.getByRole("button", { name: "Create new team" })).toBeVisible(); await page.getByRole("button", { name: "Create new team" }).click(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3d3f5677f..bc83c3f7ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7754,6 +7754,7 @@ packages: next@15.5.7: resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 From b96f0e67c59af6a42e89158693e878b44045b764 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Fri, 12 Dec 2025 10:52:48 +0530 Subject: [PATCH 03/24] fix: preserve attribute key casing during CSV contact upload (#6958) Co-authored-by: Johannes --- .../modules/ee/contacts/lib/contacts.test.ts | 57 ++---- apps/web/modules/ee/contacts/lib/contacts.ts | 186 ++++++++---------- 2 files changed, 99 insertions(+), 144 deletions(-) diff --git a/apps/web/modules/ee/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/lib/contacts.test.ts index 0aa8fd0bd1..9486e74a76 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.test.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.test.ts @@ -458,21 +458,15 @@ describe("Contacts Lib", () => { attributes: [{ attributeKey: { key: "email", id: "key-1" }, value: "john@example.com" }], }; - vi.mocked(prisma.contact.findMany) - .mockResolvedValueOnce([existingContact as any]) - .mockResolvedValueOnce([{ key: "email", id: "key-1" } as any]) - .mockResolvedValueOnce([ - { key: "userId", id: "key-2" }, - { key: "email", id: "key-1" }, - ] as any); - + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttributeKey.findMany) .mockResolvedValueOnce([{ key: "email", id: "key-1" }] as any) .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, { key: "userId", id: "key-2" }, + { key: "name", id: "key-3" }, ] as any); + vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 }); const result = await createContactsFromCSV(csvData, mockEnvironmentId, "skip", attributeMap); @@ -489,25 +483,15 @@ describe("Contacts Lib", () => { ], }; - vi.mocked(prisma.contact.findMany) - .mockResolvedValueOnce([existingContact as any]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, - { key: "userId", id: "key-2" }, - ] as any); - + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttributeKey.findMany) .mockResolvedValueOnce([ { key: "email", id: "key-1" }, { key: "userId", id: "key-2" }, ] as any) - .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, - { key: "userId", id: "key-2" }, - ] as any); - + .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any); + vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 }); vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any); const result = await createContactsFromCSV(csvData, mockEnvironmentId, "update", attributeMap); @@ -525,25 +509,15 @@ describe("Contacts Lib", () => { ], }; - vi.mocked(prisma.contact.findMany) - .mockResolvedValueOnce([existingContact as any]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, - { key: "userId", id: "key-2" }, - ] as any); - + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([existingContact as any]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttributeKey.findMany) .mockResolvedValueOnce([ { key: "email", id: "key-1" }, { key: "userId", id: "key-2" }, ] as any) - .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, - { key: "userId", id: "key-2" }, - ] as any); - + .mockResolvedValueOnce([{ key: "name", id: "key-3" }] as any); + vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 1 }); vi.mocked(prisma.contactAttribute.deleteMany).mockResolvedValue({ count: 2 }); vi.mocked(prisma.contact.update).mockResolvedValue(existingContact as any); @@ -582,23 +556,16 @@ describe("Contacts Lib", () => { test("creates missing attribute keys", async () => { const attributeMap = { email: "email", userId: "userId" }; - vi.mocked(prisma.contact.findMany) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { key: "email", id: "key-1" }, - { key: "userId", id: "key-2" }, - ] as any); - + vi.mocked(prisma.contact.findMany).mockResolvedValueOnce([]); vi.mocked(prisma.contactAttribute.findMany).mockResolvedValue([]); vi.mocked(prisma.contactAttributeKey.findMany) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { key: "email", id: "key-1" }, { key: "userId", id: "key-2" }, + { key: "name", id: "key-3" }, ] as any); - - vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 2 }); + vi.mocked(prisma.contactAttributeKey.createMany).mockResolvedValue({ count: 3 }); vi.mocked(prisma.contact.create).mockResolvedValue({ id: "new-1", environmentId: mockEnvironmentId, diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts index f1813da0f7..6fd1578017 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.ts @@ -200,6 +200,50 @@ export const deleteContact = async (contactId: string): Promise } }; +// Shared include clause for contact queries +const contactAttributesInclude = { + attributes: { + select: { + attributeKey: { select: { key: true } }, + value: true, + }, + }, +} satisfies Prisma.ContactInclude; + +// Helper to create attribute objects for Prisma create operations +const createAttributeConnections = (record: Record, environmentId: string) => + Object.entries(record).map(([key, value]) => ({ + attributeKey: { + connect: { key_environmentId: { key, environmentId } }, + }, + value, + })); + +// Helper to handle userId conflicts when updating/overwriting contacts +const resolveUserIdConflict = ( + mappedRecord: Record, + existingContact: { id: string; attributes: { attributeKey: { key: string }; value: string }[] }, + existingUserIds: { value: string; contactId: string }[] +): Record => { + const existingUserId = existingUserIds.find( + (attr) => attr.value === mappedRecord.userId && attr.contactId !== existingContact.id + ); + + if (!existingUserId) { + return { ...mappedRecord }; + } + + const { userId: _userId, ...rest } = mappedRecord; + const existingContactUserId = existingContact.attributes.find( + (attr) => attr.attributeKey.key === "userId" + )?.value; + + return { + ...rest, + ...(existingContactUserId && { userId: existingContactUserId }), + }; +}; + export const createContactsFromCSV = async ( csvData: Record[], environmentId: string, @@ -287,22 +331,36 @@ export const createContactsFromCSV = async ( }); const attributeKeyMap = new Map(); + // Map from lowercase key to actual DB key (for case-insensitive lookup) + const lowercaseToActualKeyMap = new Map(); + existingAttributeKeys.forEach((attrKey) => { attributeKeyMap.set(attrKey.key, attrKey.id); + lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key); }); - // Identify missing attribute keys (normalize keys to lowercase) + // Collect all unique CSV keys const csvKeys = new Set(); csvData.forEach((record) => { - Object.keys(record).forEach((key) => csvKeys.add(key.toLowerCase())); + Object.keys(record).forEach((key) => csvKeys.add(key)); }); - const missingKeys = Array.from(csvKeys).filter((key) => !attributeKeyMap.has(key)); + // Identify missing attribute keys (case-insensitive check) + const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase())); - // Create missing attribute keys + // Create missing attribute keys (use original CSV casing for new keys) if (missingKeys.length > 0) { + // Deduplicate by lowercase to avoid creating duplicates like "firstName" and "firstname" + const uniqueMissingKeys = new Map(); + missingKeys.forEach((key) => { + const lowerKey = key.toLowerCase(); + if (!uniqueMissingKeys.has(lowerKey)) { + uniqueMissingKeys.set(lowerKey, key); + } + }); + await prisma.contactAttributeKey.createMany({ - data: missingKeys.map((key) => ({ + data: Array.from(uniqueMissingKeys.values()).map((key) => ({ key, name: key, environmentId, @@ -310,10 +368,10 @@ export const createContactsFromCSV = async ( skipDuplicates: true, }); - // Fetch and update the attributeKeyMap with new keys + // Fetch and update the maps with new keys const newAttributeKeys = await prisma.contactAttributeKey.findMany({ where: { - key: { in: missingKeys }, + key: { in: Array.from(uniqueMissingKeys.values()) }, environmentId, }, select: { key: true, id: true }, @@ -321,6 +379,7 @@ export const createContactsFromCSV = async ( newAttributeKeys.forEach((attrKey) => { attributeKeyMap.set(attrKey.key, attrKey.id); + lowercaseToActualKeyMap.set(attrKey.key.toLowerCase(), attrKey.key); }); } @@ -328,18 +387,23 @@ export const createContactsFromCSV = async ( // Process contacts in parallel const contactPromises = csvData.map(async (record) => { - // Normalize record keys to lowercase - const normalizedRecord: Record = {}; + // Map CSV keys to actual DB keys (case-insensitive matching, preserving DB key casing) + const mappedRecord: Record = {}; Object.entries(record).forEach(([key, value]) => { - normalizedRecord[key.toLowerCase()] = value; + const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase()); + if (!actualKey) { + // This should never happen since we create missing keys above + throw new ValidationError(`Attribute key "${key}" not found in attribute key map`); + } + mappedRecord[actualKey] = value; }); // Skip records without email - if (!normalizedRecord.email) { + if (!mappedRecord.email) { throw new ValidationError("Email is required for all contacts"); } - const existingContact = emailToContactMap.get(normalizedRecord.email); + const existingContact = emailToContactMap.get(mappedRecord.email); if (existingContact) { // Handle duplicates based on duplicateContactsAction @@ -348,25 +412,7 @@ export const createContactsFromCSV = async ( return null; case "update": { - // if the record has a userId, check if it already exists - const existingUserId = existingUserIds.find( - (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id - ); - let recordToProcess = { ...normalizedRecord }; - if (existingUserId) { - const { userid, ...rest } = recordToProcess; - - const existingContactUserId = existingContact.attributes.find( - (attr) => attr.attributeKey.key === "userId" - )?.value; - - recordToProcess = { - ...rest, - ...(existingContactUserId && { - userId: existingContactUserId, - }), - }; - } + const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds); const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({ where: { @@ -383,7 +429,7 @@ export const createContactsFromCSV = async ( })); // Update contact with upserted attributes - const updatedContact = prisma.contact.update({ + return prisma.contact.update({ where: { id: existingContact.id }, data: { attributes: { @@ -391,98 +437,40 @@ export const createContactsFromCSV = async ( upsert: attributesToUpsert, }, }, - include: { - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, - }, - }, + include: contactAttributesInclude, }); - - return updatedContact; } case "overwrite": { - // if the record has a userId, check if it already exists - const existingUserId = existingUserIds.find( - (attr) => attr.value === normalizedRecord.userid && attr.contactId !== existingContact.id - ); - let recordToProcess = { ...normalizedRecord }; - if (existingUserId) { - const { userid, ...rest } = recordToProcess; - const existingContactUserId = existingContact.attributes.find( - (attr) => attr.attributeKey.key === "userId" - )?.value; - - recordToProcess = { - ...rest, - ...(existingContactUserId && { - userId: existingContactUserId, - }), - }; - } + const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds); // Overwrite by deleting existing attributes and creating new ones await prisma.contactAttribute.deleteMany({ where: { contactId: existingContact.id }, }); - const newAttributes = Object.entries(recordToProcess).map(([key, value]) => ({ - attributeKey: { - connect: { key_environmentId: { key, environmentId } }, - }, - value, - })); - - const updatedContact = prisma.contact.update({ + return prisma.contact.update({ where: { id: existingContact.id }, data: { attributes: { - create: newAttributes, - }, - }, - include: { - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, + create: createAttributeConnections(recordToProcess, environmentId), }, }, + include: contactAttributesInclude, }); - - return updatedContact; } } } else { - // Create new contact - const newAttributes = Object.entries(record).map(([key, value]) => ({ - attributeKey: { - connect: { key_environmentId: { key, environmentId } }, - }, - value, - })); - - const newContact = prisma.contact.create({ + // Create new contact - use mappedRecord with proper DB key casing + return prisma.contact.create({ data: { environmentId, attributes: { - create: newAttributes, - }, - }, - include: { - attributes: { - select: { - attributeKey: { select: { key: true } }, - value: true, - }, + create: createAttributeConnections(mappedRecord, environmentId), }, }, + include: contactAttributesInclude, }); - - return newContact; } }); From 42525a86a8c6686369ce2cae0c345b22535de096 Mon Sep 17 00:00:00 2001 From: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com> Date: Fri, 12 Dec 2025 11:33:35 +0530 Subject: [PATCH 04/24] fix: close the survey on formbricks.logout (#6955) --- packages/js-core/src/lib/common/constants.ts | 2 +- packages/js-core/src/lib/common/setup.ts | 10 ++-------- packages/js-core/src/lib/common/tests/setup.test.ts | 3 ++- packages/js-core/src/lib/survey/widget.ts | 3 +-- 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/js-core/src/lib/common/constants.ts b/packages/js-core/src/lib/common/constants.ts index 236b34d4e6..9748e4ccab 100644 --- a/packages/js-core/src/lib/common/constants.ts +++ b/packages/js-core/src/lib/common/constants.ts @@ -1,5 +1,5 @@ export const JS_LOCAL_STORAGE_KEY = "formbricks-js"; export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website"; export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app"; -export const CONTAINER_ID = "formbricks-app-container"; +export const CONTAINER_ID = "formbricks-modal-container"; export const RECAPTCHA_SCRIPT_ID = "formbricks-recaptcha-script"; diff --git a/packages/js-core/src/lib/common/setup.ts b/packages/js-core/src/lib/common/setup.ts index 27bc9da4fc..3a897f782f 100644 --- a/packages/js-core/src/lib/common/setup.ts +++ b/packages/js-core/src/lib/common/setup.ts @@ -7,7 +7,7 @@ import { getIsSetup, setIsSetup } from "@/lib/common/status"; import { filterSurveys, getIsDebug, isNowExpired, wrapThrows } from "@/lib/common/utils"; import { fetchEnvironmentState } from "@/lib/environment/state"; import { checkPageUrl } from "@/lib/survey/no-code-action"; -import { addWidgetContainer, removeWidgetContainer, setIsSurveyRunning } from "@/lib/survey/widget"; +import { closeSurvey } from "@/lib/survey/widget"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; import { sendUpdatesToBackend } from "@/lib/user/update"; import { @@ -142,9 +142,6 @@ export const setup = async ( }); } - logger.debug("Adding widget container to DOM"); - addWidgetContainer(); - if ( existingConfig?.environment && existingConfig.environmentId === configInput.environmentId && @@ -344,10 +341,7 @@ export const tearDown = (): void => { filteredSurveys, }); - // remove container element from DOM - removeWidgetContainer(); - addWidgetContainer(); - setIsSurveyRunning(false); + closeSurvey(); }; export const handleErrorOnFirstSetup = (e: { code: string; responseMessage: string }): Promise => { diff --git a/packages/js-core/src/lib/common/tests/setup.test.ts b/packages/js-core/src/lib/common/tests/setup.test.ts index 5590c72828..a9d79da5f1 100644 --- a/packages/js-core/src/lib/common/tests/setup.test.ts +++ b/packages/js-core/src/lib/common/tests/setup.test.ts @@ -290,12 +290,13 @@ describe("setup.ts", () => { test("resets user state to default", () => { const mockConfig = { get: vi.fn().mockReturnValue({ + environment: { data: { surveys: [] } }, user: { data: { userId: "XYZ" } }, }), update: vi.fn(), }; - getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as Config); + getInstanceConfigMock.mockReturnValue(mockConfig as unknown as Config); tearDown(); diff --git a/packages/js-core/src/lib/survey/widget.ts b/packages/js-core/src/lib/survey/widget.ts index d86ae4ca7a..6b524a3a1e 100644 --- a/packages/js-core/src/lib/survey/widget.ts +++ b/packages/js-core/src/lib/survey/widget.ts @@ -174,9 +174,8 @@ export const renderWidget = async ( export const closeSurvey = (): void => { const config = Config.getInstance(); - // remove container element from DOM + // remove the survey modal container from DOM removeWidgetContainer(); - addWidgetContainer(); const { environment, user } = config.get(); const filteredSurveys = filterSurveys(environment, user); From 5dbf42fd6a519964f6c2b2458e1680a23a2dab38 Mon Sep 17 00:00:00 2001 From: Johannes <72809645+jobenjada@users.noreply.github.com> Date: Thu, 11 Dec 2025 22:49:49 -0800 Subject: [PATCH 05/24] feat: add bulk edit for single-select and multi-select options (#6951) Co-authored-by: pandeymangg --- apps/web/i18n.lock | 6 + apps/web/locales/de-DE.json | 6 + apps/web/locales/en-US.json | 6 + apps/web/locales/es-ES.json | 6 + apps/web/locales/fr-FR.json | 6 + apps/web/locales/ja-JP.json | 6 + apps/web/locales/nl-NL.json | 6 + apps/web/locales/pt-BR.json | 6 + apps/web/locales/pt-PT.json | 6 + apps/web/locales/ro-RO.json | 6 + apps/web/locales/sv-SE.json | 6 + apps/web/locales/zh-Hans-CN.json | 6 + apps/web/locales/zh-Hant-TW.json | 6 + .../components/bulk-edit-options-modal.tsx | 195 ++++++++++++++++++ .../multiple-choice-element-form.tsx | 50 ++++- 15 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 46a9532cff..f3462e2ee5 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1182,6 +1182,10 @@ checksums: environments/surveys/edit/bold: 4d7306bc355ed2befd6a9237c5452ee6 environments/surveys/edit/brand_color: 84ddb5736deb9f5c081ffe4962a6c63e environments/surveys/edit/brightness: 45425b6db1872225bfff71cf619d0e64 + environments/surveys/edit/bulk_edit: 59bd1a55587c8cbad716afbf2509e5bb + environments/surveys/edit/bulk_edit_description: 9b5b2c6183c6c51689e16d7ba02ec9bb + environments/surveys/edit/bulk_edit_options: 74ebec7c53be729f33e38d7605b25815 + environments/surveys/edit/bulk_edit_options_for: 986af3a8286f34c9e4ad7c74d3c65ada environments/surveys/edit/button_external: d2de24e06574622baf1c0cdd1b718b1a environments/surveys/edit/button_external_description: cbd10d494a70b362bfee811e012c45b1 environments/surveys/edit/button_label: db3cd7c74f393187bd780c5c3d8b9b4f @@ -1428,6 +1432,7 @@ checksums: environments/surveys/edit/option_used_in_logic_error: c682ac2cfd286c3cc07dd21ac863dd4c environments/surveys/edit/optional: 396fb9a0472daf401c392bdc3e248943 environments/surveys/edit/options: 59156082418d80acb211f973b1218f11 + environments/surveys/edit/options_used_in_logic_bulk_error: 1720e7a01a0bcb67c152cfe6a68c5355 environments/surveys/edit/override_theme_with_individual_styles_for_this_survey: edffc97f5d3372419fe0444de0a5aa3f environments/surveys/edit/overwrite_global_waiting_time: 7bc23bd502b6bd048356b67acd956d9d environments/surveys/edit/overwrite_global_waiting_time_description: 795cf6e93d4c01d2e43aa0ebab601c6e @@ -1575,6 +1580,7 @@ checksums: environments/surveys/edit/unsaved_changes_warning: a164f276c9f7344022aa4640b32abcf9 environments/surveys/edit/until_they_submit_a_response: 2a0fd5dcc6cc40a72ed9b974f22eaf68 environments/surveys/edit/untitled_block: fdaa045139deff5cc65fa027df0cc22e + environments/surveys/edit/update_options: 3499161b010acdefba2d878daa5fb6fa environments/surveys/edit/upgrade_notice_description: 32b66a4f257ad8d38bc38dcc95fe23c4 environments/surveys/edit/upgrade_notice_title: 40866066ebc558ad0c92a4f19f12090c environments/surveys/edit/upload: 4a6c84aa16db0f4e5697f49b45257bc7 diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index 53a271ecfd..a7ab7b765f 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1267,6 +1267,10 @@ "bold": "Fett", "brand_color": "Markenfarbe", "brightness": "Helligkeit", + "bulk_edit": "Massenbearbeitung", + "bulk_edit_description": "Bearbeiten Sie alle Optionen unten, eine pro Zeile. Leere Zeilen werden übersprungen und Duplikate entfernt.", + "bulk_edit_options": "Optionen massenbearbeiten", + "bulk_edit_options_for": "Optionen massenbearbeiten für {language}", "button_external": "Externen Link aktivieren", "button_external_description": "Fügen Sie eine Schaltfläche hinzu, die eine externe URL in einem neuen Tab öffnet", "button_label": "Beschriftung", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Diese Option wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.", "optional": "Optional", "options": "Optionen", + "options_used_in_logic_bulk_error": "Die folgenden Optionen werden in der Logik verwendet: {questionIndexes}. Bitte entferne sie zuerst aus der Logik.", "override_theme_with_individual_styles_for_this_survey": "Styling für diese Umfrage überschreiben.", "overwrite_global_waiting_time": "Benutzerdefinierte Wartezeit festlegen", "overwrite_global_waiting_time_description": "Die Projektkonfiguration nur für diese Umfrage überschreiben.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Du hast ungespeicherte Änderungen in deiner Umfrage. Möchtest Du sie speichern, bevor Du gehst?", "until_they_submit_a_response": "Fragen, bis sie eine Antwort abgeben", "untitled_block": "Unbenannter Block", + "update_options": "Optionen aktualisieren", "upgrade_notice_description": "Erstelle mehrsprachige Umfragen und entdecke viele weitere Funktionen", "upgrade_notice_title": "Schalte mehrsprachige Umfragen mit einem höheren Plan frei", "upload": "Hochladen", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 5893d65434..6b837fcdca 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1266,6 +1266,10 @@ "bold": "Bold", "brand_color": "Brand color", "brightness": "Brightness", + "bulk_edit": "Bulk edit", + "bulk_edit_description": "Edit all options below, one per line. Empty lines will be skipped and duplicates removed.", + "bulk_edit_options": "Bulk edit options", + "bulk_edit_options_for": "Bulk edit options for {language}", "button_external": "Enable External Link", "button_external_description": "Add a button that opens an external URL in a new tab", "button_label": "Button Label", @@ -1512,6 +1516,7 @@ "option_used_in_logic_error": "This option is used in logic of question {questionIndex}. Please remove it from logic first.", "optional": "Optional", "options": "Options", + "options_used_in_logic_bulk_error": "The following options are used in logic: {questionIndexes}. Please remove them from logic first.", "override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.", "overwrite_global_waiting_time": "Set custom waiting time", "overwrite_global_waiting_time_description": "Override the project configuration for this survey only.", @@ -1661,6 +1666,7 @@ "unsaved_changes_warning": "You have unsaved changes in your survey. Would you like to save them before leaving?", "until_they_submit_a_response": "Ask until they submit a response", "untitled_block": "Untitled Block", + "update_options": "Update options", "upgrade_notice_description": "Create multilingual surveys and unlock many more features", "upgrade_notice_title": "Unlock multi-language surveys with a higher plan", "upload": "Upload", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index 2b49aa2a5a..855514f611 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1267,6 +1267,10 @@ "bold": "Negrita", "brand_color": "Color de marca", "brightness": "Brillo", + "bulk_edit": "Edición masiva", + "bulk_edit_description": "Edita todas las opciones a continuación, una por línea. Las líneas vacías se omitirán y los duplicados se eliminarán.", + "bulk_edit_options": "Edición masiva de opciones", + "bulk_edit_options_for": "Edición masiva de opciones para {language}", "button_external": "Habilitar enlace externo", "button_external_description": "Añadir un botón que abre una URL externa en una nueva pestaña", "button_label": "Etiqueta del botón", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Esta opción se utiliza en la lógica de la pregunta {questionIndex}. Por favor, elimínala de la lógica primero.", "optional": "Opcional", "options": "Opciones", + "options_used_in_logic_bulk_error": "Las siguientes opciones se utilizan en la lógica: {questionIndexes}. Por favor, elimínalas de la lógica primero.", "override_theme_with_individual_styles_for_this_survey": "Anular el tema con estilos individuales para esta encuesta.", "overwrite_global_waiting_time": "Establecer tiempo de espera personalizado", "overwrite_global_waiting_time_description": "Anular la configuración del proyecto solo para esta encuesta.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Tienes cambios sin guardar en tu encuesta. ¿Quieres guardarlos antes de salir?", "until_they_submit_a_response": "Preguntar hasta que envíen una respuesta", "untitled_block": "Bloque sin título", + "update_options": "Actualizar opciones", "upgrade_notice_description": "Crea encuestas multilingües y desbloquea muchas más funciones", "upgrade_notice_title": "Desbloquea encuestas multilingües con un plan superior", "upload": "Subir", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 8fef157bb1..bb45302af8 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1267,6 +1267,10 @@ "bold": "Gras", "brand_color": "Couleur de marque", "brightness": "Luminosité", + "bulk_edit": "Modification en masse", + "bulk_edit_description": "Modifiez toutes les options ci-dessous, une par ligne. Les lignes vides seront ignorées et les doublons supprimés.", + "bulk_edit_options": "Modifier les options en masse", + "bulk_edit_options_for": "Modifier les options en masse pour {language}", "button_external": "Activer le lien externe", "button_external_description": "Ajouter un bouton qui ouvre une URL externe dans un nouvel onglet", "button_label": "Label du bouton", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Cette option est utilisée dans la logique de la question {questionIndex}. Veuillez d'abord la supprimer de la logique.", "optional": "Optionnel", "options": "Options", + "options_used_in_logic_bulk_error": "Les options suivantes sont utilisées dans la logique : {questionIndexes}. Veuillez d'abord les supprimer de la logique.", "override_theme_with_individual_styles_for_this_survey": "Override the theme with individual styles for this survey.", "overwrite_global_waiting_time": "Définir un temps d'attente personnalisé", "overwrite_global_waiting_time_description": "Remplacer la configuration du projet pour cette enquête uniquement.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Vous avez des modifications non enregistrées dans votre enquête. Souhaitez-vous les enregistrer avant de partir ?", "until_they_submit_a_response": "Demander jusqu'à ce qu'ils soumettent une réponse", "untitled_block": "Bloc sans titre", + "update_options": "Mettre à jour les options", "upgrade_notice_description": "Créez des sondages multilingues et débloquez de nombreuses autres fonctionnalités", "upgrade_notice_title": "Débloquez les sondages multilingues avec un plan supérieur", "upload": "Télécharger", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index 3be1eae10d..7fec709261 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1267,6 +1267,10 @@ "bold": "太字", "brand_color": "ブランドカラー", "brightness": "明るさ", + "bulk_edit": "一括編集", + "bulk_edit_description": "以下のオプションを1行ずつ編集してください。空の行はスキップされ、重複は削除されます。", + "bulk_edit_options": "オプションの一括編集", + "bulk_edit_options_for": "{language}のオプションを一括編集", "button_external": "外部リンクを有効にする", "button_external_description": "新しいタブで外部URLを開くボタンを追加する", "button_label": "ボタンのラベル", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "このオプションは質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。", "optional": "オプション", "options": "オプション", + "options_used_in_logic_bulk_error": "以下のオプションはロジックで使用されています:{questionIndexes}。まず、ロジックから削除してください。", "override_theme_with_individual_styles_for_this_survey": "このフォームの個別のスタイルでテーマを上書きします。", "overwrite_global_waiting_time": "カスタム待機時間を設定する", "overwrite_global_waiting_time_description": "このフォームのみプロジェクト設定を上書きします。", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "フォームに未保存の変更があります。離れる前に保存しますか?", "until_they_submit_a_response": "回答が提出されるまで質問する", "untitled_block": "無題のブロック", + "update_options": "オプションを更新", "upgrade_notice_description": "多言語フォームを作成し、さらに多くの機能をアンロック", "upgrade_notice_title": "上位プランで多言語フォームをアンロック", "upload": "アップロード", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index eeb63ca0f5..c6871d296f 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1267,6 +1267,10 @@ "bold": "Vetgedrukt", "brand_color": "Merk kleur", "brightness": "Helderheid", + "bulk_edit": "Bulkbewerking", + "bulk_edit_description": "Bewerk alle opties hieronder, één per regel. Lege regels worden overgeslagen en duplicaten verwijderd.", + "bulk_edit_options": "Opties bulkbewerken", + "bulk_edit_options_for": "Opties bulkbewerken voor {language}", "button_external": "Externe link inschakelen", "button_external_description": "Voeg een knop toe die een externe URL in een nieuw tabblad opent", "button_label": "Knoplabel", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Deze optie wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.", "optional": "Optioneel", "options": "Opties", + "options_used_in_logic_bulk_error": "De volgende opties worden gebruikt in logica: {questionIndexes}. Verwijder ze eerst uit de logica.", "override_theme_with_individual_styles_for_this_survey": "Overschrijf het thema met individuele stijlen voor deze enquête.", "overwrite_global_waiting_time": "Stel aangepaste wachttijd in", "overwrite_global_waiting_time_description": "Overschrijf de projectconfiguratie alleen voor deze enquête.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Er zijn niet-opgeslagen wijzigingen in uw enquête. Wilt u ze bewaren voordat u vertrekt?", "until_they_submit_a_response": "Vraag totdat ze een reactie indienen", "untitled_block": "Naamloos blok", + "update_options": "Opties bijwerken", "upgrade_notice_description": "Creëer meertalige enquêtes en ontgrendel nog veel meer functies", "upgrade_notice_title": "Ontgrendel meertalige enquêtes met een hoger plan", "upload": "Uploaden", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index effc6967ed..030d3b69ce 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1267,6 +1267,10 @@ "bold": "Negrito", "brand_color": "Cor da marca", "brightness": "brilho", + "bulk_edit": "Edição em massa", + "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicatas removidas.", + "bulk_edit_options": "Editar opções em massa", + "bulk_edit_options_for": "Editar opções em massa para {language}", "button_external": "Habilitar link externo", "button_external_description": "Adicionar um botão que abre uma URL externa em uma nova aba", "button_label": "Rótulo do Botão", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "optional": "Opcional", "options": "Opções", + "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.", "override_theme_with_individual_styles_for_this_survey": "Substitua o tema com estilos individuais para essa pesquisa.", "overwrite_global_waiting_time": "Definir tempo de espera personalizado", "overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para esta pesquisa.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Você tem alterações não salvas na sua pesquisa. Quer salvar antes de sair?", "until_they_submit_a_response": "Perguntar até que enviem uma resposta", "untitled_block": "Bloco sem título", + "update_options": "Atualizar opções", "upgrade_notice_description": "Crie pesquisas multilíngues e desbloqueie muitas outras funcionalidades", "upgrade_notice_title": "Desbloqueie pesquisas multilíngues com um plano superior", "upload": "Enviar", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 36648ae707..fc505dff7f 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1267,6 +1267,10 @@ "bold": "Negrito", "brand_color": "Cor da marca", "brightness": "Brilho", + "bulk_edit": "Edição em massa", + "bulk_edit_description": "Edite todas as opções abaixo, uma por linha. Linhas vazias serão ignoradas e duplicados removidos.", + "bulk_edit_options": "Editar opções em massa", + "bulk_edit_options_for": "Editar opções em massa para {language}", "button_external": "Ativar link externo", "button_external_description": "Adicionar um botão que abre um URL externo num novo separador", "button_label": "Rótulo do botão", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Esta opção é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.", "optional": "Opcional", "options": "Opções", + "options_used_in_logic_bulk_error": "As seguintes opções são usadas na lógica: {questionIndexes}. Por favor, remova-as da lógica primeiro.", "override_theme_with_individual_styles_for_this_survey": "Substituir o tema com estilos individuais para este inquérito.", "overwrite_global_waiting_time": "Definir tempo de espera personalizado", "overwrite_global_waiting_time_description": "Substituir a configuração do projeto apenas para este inquérito.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Tem alterações não guardadas no seu inquérito. Gostaria de as guardar antes de sair?", "until_they_submit_a_response": "Perguntar até que submetam uma resposta", "untitled_block": "Bloco sem título", + "update_options": "Atualizar opções", "upgrade_notice_description": "Crie inquéritos multilingues e desbloqueie muitas mais funcionalidades", "upgrade_notice_title": "Desbloqueie inquéritos multilingues com um plano superior", "upload": "Carregar", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index a2e611096e..4022425739 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1267,6 +1267,10 @@ "bold": "Îngroșat", "brand_color": "Culoarea brandului", "brightness": "Luminozitate", + "bulk_edit": "Editare în bloc", + "bulk_edit_description": "Editați toate opțiunile de mai jos, câte una pe linie. Liniile goale vor fi omise, iar duplicatele vor fi eliminate.", + "bulk_edit_options": "Opțiuni de editare în bloc", + "bulk_edit_options_for": "Editare în bloc a opțiunilor pentru {language}", "button_external": "Activează link extern", "button_external_description": "Adaugă un buton care deschide un URL extern într-o filă nouă", "button_label": "Etichetă buton", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Această opțiune este folosită în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.", "optional": "Opțional", "options": "Opțiuni", + "options_used_in_logic_bulk_error": "Următoarele opțiuni sunt folosite în logică: {questionIndexes}. Vă rugăm să le eliminați din logică mai întâi.", "override_theme_with_individual_styles_for_this_survey": "Suprascrie tema cu stiluri individuale pentru acest sondaj.", "overwrite_global_waiting_time": "Setează un timp de așteptare personalizat", "overwrite_global_waiting_time_description": "Suprascrie configurația proiectului doar pentru acest sondaj.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Aveți modificări nesalvate în sondajul dumneavoastră. Doriți să le salvați înainte de a pleca?", "until_they_submit_a_response": "Întreabă până când trimit un răspuns", "untitled_block": "Bloc fără titlu", + "update_options": "Actualizați opțiunile", "upgrade_notice_description": "Creați sondaje multilingve și deblocați multe alte caracteristici", "upgrade_notice_title": "Deblocați sondajele multilingve cu un plan superior", "upload": "Încărcați", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 268221f15f..17ef00c38c 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1267,6 +1267,10 @@ "bold": "Fet", "brand_color": "Varumärkesfärg", "brightness": "Ljusstyrka", + "bulk_edit": "Massredigera", + "bulk_edit_description": "Redigera alla alternativ nedan, ett per rad. Tomma rader kommer att hoppas över och dubbletter tas bort.", + "bulk_edit_options": "Massredigera alternativ", + "bulk_edit_options_for": "Massredigera alternativ för {language}", "button_external": "Aktivera extern länk", "button_external_description": "Lägg till en knapp som öppnar en extern URL i en ny flik", "button_label": "Knappetikett", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "Detta alternativ används i logiken för fråga {questionIndex}. Vänligen ta bort det från logiken först.", "optional": "Valfritt", "options": "Alternativ", + "options_used_in_logic_bulk_error": "Följande alternativ används i logiken: {questionIndexes}. Vänligen ta bort dem från logiken först.", "override_theme_with_individual_styles_for_this_survey": "Åsidosätt temat med individuella stilar för denna enkät.", "overwrite_global_waiting_time": "Ställ in anpassad väntetid", "overwrite_global_waiting_time_description": "Åsidosätt projektkonfigurationen endast för denna enkät.", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "Du har osparade ändringar i din enkät. Vill du spara dem innan du lämnar?", "until_they_submit_a_response": "Fråga tills de skickar in ett svar", "untitled_block": "Namnlöst block", + "update_options": "Uppdatera alternativ", "upgrade_notice_description": "Skapa flerspråkiga enkäter och lås upp många fler funktioner", "upgrade_notice_title": "Lås upp flerspråkiga enkäter med en högre plan", "upload": "Ladda upp", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index eaa8632fcf..4ebd8a82d0 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1267,6 +1267,10 @@ "bold": "粗体", "brand_color": "品牌 颜色", "brightness": "亮度", + "bulk_edit": "批量编辑", + "bulk_edit_description": "编辑以下所有选项,每行一个。空行将被跳过,重复项将被移除。", + "bulk_edit_options": "批量编辑选项", + "bulk_edit_options_for": "为 {language} 批量编辑选项", "button_external": "启用外部链接", "button_external_description": "添加一个按钮,在新标签页中打开外部URL", "button_label": "按钮标签", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "\"这个 选项 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"", "optional": "可选", "options": "选项", + "options_used_in_logic_bulk_error": "以下选项在逻辑中被使用:{questionIndexes}。请先从逻辑中删除它们。", "override_theme_with_individual_styles_for_this_survey": "使用 个性化 样式 替代 这份 问卷 的 主题。", "overwrite_global_waiting_time": "设置自定义等待时间", "overwrite_global_waiting_time_description": "仅为此调查覆盖项目配置。", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "您在调查中有未保存的更改。离开前是否要保存?", "until_they_submit_a_response": "持续显示直到提交回应", "untitled_block": "未命名区块", + "update_options": "更新选项", "upgrade_notice_description": "创建 多语言 调查 并 解锁 更多 功能", "upgrade_notice_title": "解锁 更高 计划 中 的 多语言 调查", "upload": "上传", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index ebdbbf045c..f0df904e6d 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1267,6 +1267,10 @@ "bold": "粗體", "brand_color": "品牌顏色", "brightness": "亮度", + "bulk_edit": "批次編輯", + "bulk_edit_description": "在下方逐行編輯所有選項。空白行將被略過,重複項目將被移除。", + "bulk_edit_options": "批次編輯選項", + "bulk_edit_options_for": "為 {language} 批次編輯選項", "button_external": "啟用外部連結", "button_external_description": "新增一個按鈕,在新分頁中開啟外部網址", "button_label": "按鈕標籤", @@ -1513,6 +1517,7 @@ "option_used_in_logic_error": "此選項用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。", "optional": "選填", "options": "選項", + "options_used_in_logic_bulk_error": "以下選項已用於邏輯中:{questionIndexes}。請先從邏輯中移除它們。", "override_theme_with_individual_styles_for_this_survey": "使用此問卷的個別樣式覆寫主題。", "overwrite_global_waiting_time": "設定自訂等待時間", "overwrite_global_waiting_time_description": "僅覆蓋此問卷的專案設定。", @@ -1662,6 +1667,7 @@ "unsaved_changes_warning": "您的問卷中有未儲存的變更。您要先儲存它們再離開嗎?", "until_they_submit_a_response": "持續詢問直到提交回應", "untitled_block": "未命名區塊", + "update_options": "更新選項", "upgrade_notice_description": "建立多語言問卷並解鎖更多功能", "upgrade_notice_title": "使用更高等級的方案解鎖多語言問卷", "upload": "上傳", diff --git a/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx new file mode 100644 index 0000000000..1625167910 --- /dev/null +++ b/apps/web/modules/survey/editor/components/bulk-edit-options-modal.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { createId } from "@paralleldrive/cuid2"; +import { type JSX, useEffect, useMemo, useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; +import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils"; +import { TI18nString } from "@formbricks/types/i18n"; +import { TSurveyMultipleChoiceElement } from "@formbricks/types/surveys/elements"; +import { TSurvey } from "@formbricks/types/surveys/types"; +import { TUserLocale } from "@formbricks/types/user"; +import { createI18nString } from "@/lib/i18n/utils"; +import { findElementLocation } from "@/modules/survey/editor/lib/blocks"; +import { findOptionUsedInLogic } from "@/modules/survey/editor/lib/utils"; +import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils"; +import { Button } from "@/modules/ui/components/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/modules/ui/components/dialog"; + +interface BulkEditOptionsModalProps { + isOpen: boolean; + onClose: () => void; + regularChoices: TSurveyMultipleChoiceElement["choices"]; + onSave: (updatedChoices: TSurveyMultipleChoiceElement["choices"]) => void; + element: TSurveyMultipleChoiceElement; + localSurvey: TSurvey; + selectedLanguageCode: string; + surveyLanguageCodes: string[]; + locale: TUserLocale; +} + +const parseUniqueLines = (content: string): string[] => { + return [ + ...new Set( + content + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + ), + ]; +}; + +const updateChoiceLabel = ( + choice: TSurveyMultipleChoiceElement["choices"][number], + newLabel: string, + selectedLangCode: string, + allLangCodes: string[] +): TSurveyMultipleChoiceElement["choices"][number] => { + const label = Object.fromEntries([ + ...allLangCodes.map((code) => [code, choice.label[code] ?? ""]), + [selectedLangCode, newLabel], + ]) as TI18nString; + + return { ...choice, label }; +}; + +export const BulkEditOptionsModal = ({ + isOpen, + onClose, + regularChoices, + onSave, + element, + localSurvey, + selectedLanguageCode, + surveyLanguageCodes, + locale, +}: BulkEditOptionsModalProps): JSX.Element => { + const { t } = useTranslation(); + const [textareaValue, setTextareaValue] = useState(""); + const [validationError, setValidationError] = useState(null); + + const selectedLanguageName = useMemo(() => { + if (localSurvey.languages.length <= 1) return null; + const code = + selectedLanguageCode === "default" + ? localSurvey.languages.find((lang) => lang.default)?.language.code + : selectedLanguageCode; + return code ? getLanguageLabel(code, locale) : null; + }, [localSurvey.languages, selectedLanguageCode, locale]); + + useEffect(() => { + if (isOpen) { + setTextareaValue(regularChoices.map((c) => c.label[selectedLanguageCode] || "").join("\n")); + setValidationError(null); + } + }, [isOpen, regularChoices, selectedLanguageCode]); + + const validateRemovedOptions = (newLabels: string[]): string | null => { + const originalLabels = regularChoices.map((c) => c.label[selectedLanguageCode] || ""); + const missingLabels = originalLabels.filter((label) => label && !newLabels.includes(label)); + + if (missingLabels.length === 0) return null; + + // Find which choices have missing labels and check if they're used in logic + const choicesWithMissingLabels = missingLabels + .map((label) => regularChoices.find((c) => c.label[selectedLanguageCode] === label)) + .filter((c): c is TSurveyMultipleChoiceElement["choices"][number] => c !== undefined); + + // Get all elements to find which block has the logic + const allElements = getElementsFromBlocks(localSurvey.blocks); + + // Build detailed error info: option label -> block name where it's used + const problematicOptions: { optionLabel: string; blockName: string }[] = []; + + for (const choice of choicesWithMissingLabels) { + const elementIndex = findOptionUsedInLogic(localSurvey, element.id, choice.id); + if (elementIndex !== -1) { + const elementWithLogic = allElements[elementIndex]; + // Find which block contains this element + const { block } = findElementLocation(localSurvey, elementWithLogic.id); + if (block) { + const optionLabel = choice.label[selectedLanguageCode] || ""; + problematicOptions.push({ optionLabel, blockName: block.name }); + } + } + } + + if (problematicOptions.length === 0) return null; + + // Format: "Option '3' is used in logic at 'Block Name'" + const details = problematicOptions.map((opt) => `"${opt.optionLabel}" → ${opt.blockName}`).join(", "); + + return t("environments.surveys.edit.options_used_in_logic_bulk_error", { + questionIndexes: details, + }); + }; + + const handleSave = () => { + const newLabels = parseUniqueLines(textareaValue); + const error = validateRemovedOptions(newLabels); + + if (error) { + setValidationError(error); + return; + } + + const updatedChoices = newLabels.map((label, idx) => + idx < regularChoices.length + ? updateChoiceLabel(regularChoices[idx], label, selectedLanguageCode, surveyLanguageCodes) + : { id: createId(), label: createI18nString(label, surveyLanguageCodes) } + ); + + onSave(updatedChoices); + onClose(); + toast.success(t("environments.surveys.edit.changes_saved")); + }; + + return ( + + + + + {selectedLanguageName + ? t("environments.surveys.edit.bulk_edit_options_for", { language: selectedLanguageName }) + : t("environments.surveys.edit.bulk_edit_options")} + + {t("environments.surveys.edit.bulk_edit_description")} + + +
+