diff --git a/.env.example b/.env.example index aa0e30df677e..5b457648f6d9 100644 --- a/.env.example +++ b/.env.example @@ -221,6 +221,9 @@ PASSWORD_RESET_DISABLED=1 # Organization Invite. Disable the ability for invited users to create an account. # INVITE_DISABLED=1 +# Also apply the signup email-domain check to invited users. "1" to enable. +# SIGNUP_DOMAIN_CHECK_ON_INVITES=1 + ########## # Other # ########## diff --git a/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx b/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx index e009345396d0..9299f6c74628 100644 --- a/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx +++ b/apps/web/app/(app)/billing-confirmation/components/ConfirmationPage.tsx @@ -1,17 +1,24 @@ "use client"; -import Link from "next/link"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { waitForBillingPlanAction } from "@/modules/ee/billing/actions"; import { Button } from "@/modules/ui/components/button"; import { Confetti } from "@/modules/ui/components/confetti"; const BILLING_CONFIRMATION_ORGANIZATION_ID_KEY = "billingConfirmationOrganizationId"; +const SYNCABLE_PLANS = ["pro", "scale"] as const; +type TSyncablePlan = (typeof SYNCABLE_PLANS)[number]; + +const isSyncablePlan = (plan: string | null): plan is TSyncablePlan => + plan !== null && (SYNCABLE_PLANS as readonly string[]).includes(plan); export const ConfirmationPage = () => { const { t } = useTranslation(); const [showConfetti, setShowConfetti] = useState(false); const [resolvedOrganizationId, setResolvedOrganizationId] = useState(null); + // Block the back link until Stripe is synced, so returning to billing renders the new plan. + const [isSyncing, setIsSyncing] = useState(false); useEffect(() => { setShowConfetti(true); @@ -22,7 +29,8 @@ export const ConfirmationPage = () => { // Prefer the organizationId Stripe appends to the return URL so the back link survives a // fresh tab / cleared session storage; fall back to session storage otherwise. - const urlOrganizationId = new URLSearchParams(globalThis.window.location.search).get("organizationId"); + const params = new URLSearchParams(globalThis.window.location.search); + const urlOrganizationId = params.get("organizationId"); const storedOrganizationId = globalThis.window.sessionStorage.getItem( BILLING_CONFIRMATION_ORGANIZATION_ID_KEY ); @@ -30,8 +38,32 @@ export const ConfirmationPage = () => { if (organizationId) { setResolvedOrganizationId(organizationId); } + + // The read-through sync only refreshes a >5min-stale snapshot, so the fresh post-checkout snapshot + // still shows the old plan. Force a Stripe sync here (poll until the purchased plan lands) so the + // billing page renders the new plan on return. + const plan = params.get("plan"); + if (!organizationId || !isSyncablePlan(plan)) { + return; + } + + let cancelled = false; + setIsSyncing(true); + void waitForBillingPlanAction({ organizationId, targetPlan: plan }).finally(() => { + if (!cancelled) { + setIsSyncing(false); + } + }); + + return () => { + cancelled = true; + }; }, []); + const billingHref = resolvedOrganizationId + ? `/organizations/${resolvedOrganizationId}/settings/billing` + : "/"; + return (
{showConfetti && } @@ -44,12 +76,18 @@ export const ConfirmationPage = () => { {t("billing_confirmation.thanks_for_upgrading")}

- + + ) : ( + + )} ); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts index 4243ecc2886f..dde1e74d8046 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.test.ts @@ -108,6 +108,7 @@ const getBaseSurvey = () => ({ id: surveyId, workspaceId, + status: "inProgress", blocks: [], questions: [], }) as const; @@ -317,6 +318,23 @@ describe("putResponseHandler", () => { expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled(); }); + test("rejects updates when the survey is closed (not accepting submissions)", async () => { + mocks.getSurvey.mockResolvedValue({ + ...getBaseSurvey(), + status: "completed", + }); + + const result = await putResponseHandler(createHandlerParams()); + + expect(result.response.status).toBe(403); + await expect(result.response.json()).resolves.toEqual({ + code: "forbidden", + message: "Survey is not accepting submissions", + details: { surveyId }, + }); + expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled(); + }); + test("rejects invalid file upload updates", async () => { mocks.validateClientFileUploads.mockReturnValue(false); diff --git a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts index 950e3ff01a25..de40714ffe4b 100644 --- a/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts +++ b/apps/web/app/api/v1/client/[workspaceId]/responses/[responseId]/lib/put-response-handler.ts @@ -135,6 +135,16 @@ const validateUpdateRequest = ( responseUpdateInput: TResponseUpdateInput, workspaceId: string ): TRouteResult | undefined => { + // Mirror the POST endpoint: a closed survey accepts no submissions, including finalizing a partial + // response created while it was open (ENG-1654). + if (survey.status !== "inProgress") { + return { + response: responses.forbiddenResponse("Survey is not accepting submissions", true, { + surveyId: survey.id, + }), + }; + } + if (existingResponse.finished) { return { response: responses.badRequestResponse( diff --git a/apps/web/app/api/v3/surveys/patch.test.ts b/apps/web/app/api/v3/surveys/patch.test.ts index a25849b4164f..000c7c51a2a7 100644 --- a/apps/web/app/api/v3/surveys/patch.test.ts +++ b/apps/web/app/api/v3/surveys/patch.test.ts @@ -425,6 +425,29 @@ describe("patchV3Survey", () => { ); }); + test("rejects setting a non-draft status on an app survey with no triggers", async () => { + await expect( + executeV3SurveyPatch({ + currentSurvey: { ...currentSurvey, type: "app", triggers: [] } as TSurvey, + document: { + name: currentSurvey.name, + status: "inProgress", + metadata: currentSurvey.metadata, + defaultLanguage: "en-US", + languages: [{ code: "en-US", enabled: true }], + welcomeCard: currentSurvey.welcomeCard, + blocks: currentSurvey.blocks, + endings: currentSurvey.endings, + hiddenFields: currentSurvey.hiddenFields, + variables: currentSurvey.variables, + } as unknown as Parameters[0]["document"], + languageRequests: [{ code: "en-US", default: true, enabled: true }], + requestId: "req_1", + }) + ).rejects.toBeInstanceOf(V3SurveyReferenceValidationError); + expect(prisma.survey.update).not.toHaveBeenCalled(); + }); + test("reconciles due survey schedules without refetching when no schedule transition persisted", async () => { vi.mocked(isSurveySchedulingDue).mockReturnValue(true); vi.mocked(reconcileDueSurveySchedules).mockResolvedValue({ diff --git a/apps/web/app/api/v3/surveys/patch.ts b/apps/web/app/api/v3/surveys/patch.ts index f8751ac1a5e0..193418624acd 100644 --- a/apps/web/app/api/v3/surveys/patch.ts +++ b/apps/web/app/api/v3/surveys/patch.ts @@ -5,7 +5,12 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import type { TSurvey } from "@formbricks/types/surveys/types"; import { getActionClasses } from "@/lib/actionClass/service"; import { selectSurvey } from "@/lib/survey/service"; -import { stripIsDraftFromBlocks, transformPrismaSurvey } from "@/lib/survey/utils"; +import { + APP_SURVEY_TRIGGER_REQUIRED_MESSAGE, + isAppSurveyMissingTriggersToPublish, + stripIsDraftFromBlocks, + transformPrismaSurvey, +} from "@/lib/survey/utils"; import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates"; import { isSurveySchedulingDue, @@ -174,6 +179,16 @@ export async function executeV3SurveyPatch(params: { throw new V3SurveyReferenceValidationError(mediaInvalidParams); } + // An app survey can never be shown without a trigger, so reject setting a non-draft status with no + // effective triggers. Triggers only change when the patch carries a `distribution` (top-level + // replacement); otherwise the survey keeps its current triggers. + const effectiveTriggers = document.distribution ? document.distribution.triggers : currentSurvey.triggers; + if (isAppSurveyMissingTriggersToPublish(currentSurvey.type, document.status, effectiveTriggers)) { + throw new V3SurveyReferenceValidationError([ + { name: "triggers", reason: APP_SURVEY_TRIGGER_REQUIRED_MESSAGE }, + ]); + } + const languages = await ensureV3WorkspaceLanguages(currentSurvey.workspaceId, languageRequests, requestId); const normalizedScheduling = normalizeSurveyScheduling({ currentStatus: currentSurvey.status, diff --git a/apps/web/i18n.lock b/apps/web/i18n.lock index 82c1bbbe6fd8..2835bee39fd9 100644 --- a/apps/web/i18n.lock +++ b/apps/web/i18n.lock @@ -1595,6 +1595,7 @@ checksums: workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595 workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71 + workspace/analysis/charts/edit_chart_title_named: 216b4226fb611b3694bc222026722845 workspace/analysis/charts/emotion_value_anger: 4fd64fc804c247fc3fcbc7dc147b62fe workspace/analysis/charts/emotion_value_disgust: e5860fbfc609c8c8686db8a18cadf932 workspace/analysis/charts/emotion_value_fear: b7621ac47c2543dff100f55f948df8cf @@ -1732,6 +1733,8 @@ checksums: workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190 workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa + workspace/analysis/dashboards/chart_duplicate_failed: 90d7166c85188b52f821c9d9f53ff8c4 + workspace/analysis/dashboards/chart_duplicated: 52765f173bd6fd5382731f8e06e436e0 workspace/analysis/dashboards/chart_remove_failed: 198f5c3bdd522b40b80cb7479d3051a1 workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd workspace/analysis/dashboards/chart_restore_failed: 890c74046ed55df6f90c28cf905b7a30 diff --git a/apps/web/lib/survey/service.test.ts b/apps/web/lib/survey/service.test.ts index 8a181a65ace9..4e4830665199 100644 --- a/apps/web/lib/survey/service.test.ts +++ b/apps/web/lib/survey/service.test.ts @@ -552,7 +552,7 @@ describe("Tests for createSurvey", () => { name: "Test Survey", type: "app" as const, createdBy: mockUserId, - status: "inProgress" as const, + status: "draft" as const, welcomeCard: { enabled: true, headline: { default: "Welcome" }, @@ -663,6 +663,13 @@ describe("Tests for createSurvey", () => { expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled(); }); + test("throws InvalidInputError when creating a non-draft app survey with no triggers", async () => { + await expect( + createSurvey(mockWorkspaceId, { ...mockCreateSurveyInput, type: "app", status: "inProgress" }) + ).rejects.toThrow(InvalidInputError); + expect(prisma.survey.create).not.toHaveBeenCalled(); + }); + test("creates a private segment for app surveys", async () => { vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput); prisma.survey.create.mockResolvedValueOnce({ diff --git a/apps/web/lib/survey/service.ts b/apps/web/lib/survey/service.ts index 8530ae7b12e4..17381cdba105 100644 --- a/apps/web/lib/survey/service.ts +++ b/apps/web/lib/survey/service.ts @@ -22,8 +22,10 @@ import { getActionClasses } from "../actionClass/service"; import { ITEMS_PER_PAGE } from "../constants"; import { validateInputs } from "../utils/validate"; import { + APP_SURVEY_TRIGGER_REQUIRED_MESSAGE, checkForInvalidImagesInQuestions, checkForInvalidMediaInBlocks, + isAppSurveyMissingTriggersToPublish, stripIsDraftFromBlocks, transformPrismaSurvey, validateMediaAndPrepareBlocks, @@ -293,6 +295,12 @@ export const updateSurveyInternal = async ( if (!skipValidation) { checkForInvalidImagesInQuestions(questions); + + // An app survey can never be shown without a trigger, so block publishing (non-draft status) + // one with zero triggers. The editor enforces this client-side only; mirror it server-side. + if (isAppSurveyMissingTriggersToPublish(type, updatedSurvey.status, triggers)) { + throw new InvalidInputError(APP_SURVEY_TRIGGER_REQUIRED_MESSAGE); + } } // Add blocks media validation. The validation error is already an InvalidInputError, so the @@ -665,6 +673,18 @@ export const createSurvey = async ( const { createdBy, languages, segment, followUps, styling, ...restSurveyBody } = parsedSurveyBody; assertSurveyLanguagesBelongToWorkspace(parsedWorkspaceId, languages); await assertSurveySegmentBelongsToWorkspace(parsedWorkspaceId, segment); + + // An app survey can never be shown without a trigger, so block creating one directly in a + // non-draft status with zero triggers (mirrors the editor's publish guard). + if ( + isAppSurveyMissingTriggersToPublish( + restSurveyBody.type ?? "link", + restSurveyBody.status ?? "draft", + restSurveyBody.triggers + ) + ) { + throw new InvalidInputError(APP_SURVEY_TRIGGER_REQUIRED_MESSAGE); + } const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null; const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null; const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined = diff --git a/apps/web/lib/survey/utils.test.ts b/apps/web/lib/survey/utils.test.ts index d585130bc4ab..4704fc873136 100644 --- a/apps/web/lib/survey/utils.test.ts +++ b/apps/web/lib/survey/utils.test.ts @@ -11,10 +11,28 @@ import { anySurveyHasFilters, checkForInvalidImagesInQuestions, checkForInvalidMediaInBlocks, + isAppSurveyMissingTriggersToPublish, transformPrismaSurvey, validateMediaAndPrepareBlocks, } from "./utils"; +describe("isAppSurveyMissingTriggersToPublish", () => { + test("flags a non-draft app survey with no triggers", () => { + expect(isAppSurveyMissingTriggersToPublish("app", "inProgress", [])).toBe(true); + expect(isAppSurveyMissingTriggersToPublish("app", "paused", null)).toBe(true); + expect(isAppSurveyMissingTriggersToPublish("app", "completed", undefined)).toBe(true); + }); + + test("allows a non-draft app survey that has at least one trigger", () => { + expect(isAppSurveyMissingTriggersToPublish("app", "inProgress", [{ actionClassId: "a" }])).toBe(false); + }); + + test("ignores draft app surveys and non-app surveys", () => { + expect(isAppSurveyMissingTriggersToPublish("app", "draft", [])).toBe(false); + expect(isAppSurveyMissingTriggersToPublish("link", "inProgress", [])).toBe(false); + }); +}); + describe("transformPrismaSurvey", () => { test("transforms prisma survey without segment", () => { const surveyPrisma = { diff --git a/apps/web/lib/survey/utils.ts b/apps/web/lib/survey/utils.ts index a1aae2f27715..531d9acd874b 100644 --- a/apps/web/lib/survey/utils.ts +++ b/apps/web/lib/survey/utils.ts @@ -10,10 +10,30 @@ import { TSurveyElementTypeEnum, TSurveyPictureChoice, } from "@formbricks/types/surveys/elements"; -import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types"; +import { + TSurvey, + TSurveyQuestion, + TSurveyQuestionTypeEnum, + TSurveyStatus, + TSurveyType, +} from "@formbricks/types/surveys/types"; import { isValidVideoUrl } from "@/lib/utils/video-upload"; import { isValidImageFile } from "@/modules/storage/utils"; +/** + * A live (non-draft) app survey must have at least one trigger, otherwise it can never be displayed + * ("live but dormant"). The survey editor only enforces this client-side, so every server write path + * (create + update, including the v3 API) applies this check to keep the API in parity with the UI. + */ +export const APP_SURVEY_TRIGGER_REQUIRED_MESSAGE = + "An app survey must have at least one trigger to be set to a non-draft status."; + +export const isAppSurveyMissingTriggersToPublish = ( + type: TSurveyType, + status: TSurveyStatus, + triggers: readonly unknown[] | null | undefined +): boolean => type === "app" && status !== "draft" && (triggers?.length ?? 0) === 0; + /** * Actionable hint appended to invalid-image messages. Names the supported formats and explains why * extension-less external/CDN URLs (e.g. Unsplash, signed URLs, `?format=auto`) are rejected, since diff --git a/apps/web/locales/de-DE.json b/apps/web/locales/de-DE.json index ae15b68ce8aa..05d6b64ed864 100644 --- a/apps/web/locales/de-DE.json +++ b/apps/web/locales/de-DE.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Gruppiere Daten nach Stimmung, Fragetyp und anderen Dimensionen.", "edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.", "edit_chart_title": "Diagramm bearbeiten", + "edit_chart_title_named": "„{name}“ bearbeiten", "emotion_value_anger": "Wut", "emotion_value_disgust": "Ekel", "emotion_value_fear": "Angst", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "{count} Diagramm(e) hinzufügen", + "chart_duplicate_failed": "Diagramm konnte nicht dupliziert werden", + "chart_duplicated": "Diagramm wurde dupliziert und zum Dashboard hinzugefügt", "chart_remove_failed": "Diagramm konnte nicht entfernt werden", "chart_removed": "Diagramm vom Dashboard entfernt", "chart_restore_failed": "Diagramm konnte nicht wiederhergestellt werden", diff --git a/apps/web/locales/en-US.json b/apps/web/locales/en-US.json index 9d3059249fc5..a80112eb760e 100644 --- a/apps/web/locales/en-US.json +++ b/apps/web/locales/en-US.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.", "edit_chart_description": "View and edit your chart configuration.", "edit_chart_title": "Edit Chart", + "edit_chart_title_named": "Edit \"{name}\"", "emotion_value_anger": "Anger", "emotion_value_disgust": "Disgust", "emotion_value_fear": "Fear", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Add {count} chart(s)", + "chart_duplicate_failed": "Failed to duplicate chart", + "chart_duplicated": "Chart duplicated and added to dashboard", "chart_remove_failed": "Failed to remove chart", "chart_removed": "Chart removed from dashboard", "chart_restore_failed": "Failed to restore chart", diff --git a/apps/web/locales/es-ES.json b/apps/web/locales/es-ES.json index a2e3c233720a..79be1ce53579 100644 --- a/apps/web/locales/es-ES.json +++ b/apps/web/locales/es-ES.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Agrupa los datos por sentimiento, tipo de pregunta y otras dimensiones.", "edit_chart_description": "Visualiza y edita la configuración de tu gráfico.", "edit_chart_title": "Editar gráfico", + "edit_chart_title_named": "Editar \"{name}\"", "emotion_value_anger": "Enfado", "emotion_value_disgust": "Disgusto", "emotion_value_fear": "Miedo", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Añadir {count} gráfico(s)", + "chart_duplicate_failed": "No se pudo duplicar el gráfico", + "chart_duplicated": "Gráfico duplicado y añadido al panel", "chart_remove_failed": "No se pudo eliminar el gráfico", "chart_removed": "Gráfico eliminado del panel", "chart_restore_failed": "No se pudo restaurar el gráfico", diff --git a/apps/web/locales/fr-FR.json b/apps/web/locales/fr-FR.json index 01127e324f4d..05292ec2aade 100644 --- a/apps/web/locales/fr-FR.json +++ b/apps/web/locales/fr-FR.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Groupe les données par sentiment, type de question et autres dimensions.", "edit_chart_description": "Consultez et modifiez la configuration de votre graphique.", "edit_chart_title": "Modifier le graphique", + "edit_chart_title_named": "Modifier \"{name}\"", "emotion_value_anger": "Colère", "emotion_value_disgust": "Dégoût", "emotion_value_fear": "Peur", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Ajouter {count} graphique(s)", + "chart_duplicate_failed": "Échec de la duplication du graphique", + "chart_duplicated": "Graphique dupliqué et ajouté au tableau de bord", "chart_remove_failed": "Échec de la suppression du graphique", "chart_removed": "Graphique retiré du tableau de bord", "chart_restore_failed": "Échec de la restauration du graphique", diff --git a/apps/web/locales/hu-HU.json b/apps/web/locales/hu-HU.json index 109df875d69f..78b0bb3262e1 100644 --- a/apps/web/locales/hu-HU.json +++ b/apps/web/locales/hu-HU.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Adatok csoportosítása hangulat, kérdéstípus és egyéb dimenziók szerint.", "edit_chart_description": "Diagram beállításainak megtekintése és szerkesztése.", "edit_chart_title": "Diagram szerkesztése", + "edit_chart_title_named": "„{name}“ szerkesztése", "emotion_value_anger": "Düh", "emotion_value_disgust": "Undor", "emotion_value_fear": "Félelem", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "{count} diagram hozzáadása", + "chart_duplicate_failed": "A diagram másolása sikertelen volt", + "chart_duplicated": "A diagram másolása megtörtént és hozzáadásra került a műszerfalhoz", "chart_remove_failed": "A diagram eltávolítása sikertelen volt", "chart_removed": "A diagram eltávolítva a vezérlőpultról", "chart_restore_failed": "A diagram visszaállítása sikertelen volt", diff --git a/apps/web/locales/ja-JP.json b/apps/web/locales/ja-JP.json index c6f70ed1480e..3e46b6d85f86 100644 --- a/apps/web/locales/ja-JP.json +++ b/apps/web/locales/ja-JP.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "センチメント、質問タイプ、その他のディメンションでデータをグループ化します。", "edit_chart_description": "チャート設定を表示および編集します。", "edit_chart_title": "チャートを編集", + "edit_chart_title_named": "「{name}」を編集", "emotion_value_anger": "怒り", "emotion_value_disgust": "嫌悪", "emotion_value_fear": "恐れ", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "{count}個のグラフを追加", + "chart_duplicate_failed": "グラフの複製に失敗しました", + "chart_duplicated": "グラフが複製されダッシュボードに追加されました", "chart_remove_failed": "チャートの削除に失敗しました", "chart_removed": "チャートがダッシュボードから削除されました", "chart_restore_failed": "チャートの復元に失敗しました", diff --git a/apps/web/locales/nl-NL.json b/apps/web/locales/nl-NL.json index 905df18d7692..712f23b7d486 100644 --- a/apps/web/locales/nl-NL.json +++ b/apps/web/locales/nl-NL.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Groepeer data op sentiment, vraagtype en andere dimensies.", "edit_chart_description": "Bekijk en bewerk je diagramconfiguratie.", "edit_chart_title": "Diagram bewerken", + "edit_chart_title_named": "Bewerk \"{name}\"", "emotion_value_anger": "Woede", "emotion_value_disgust": "Afkeer", "emotion_value_fear": "Angst", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "{count} grafiek(en) toevoegen", + "chart_duplicate_failed": "Dupliceren van grafiek mislukt", + "chart_duplicated": "Grafiek gedupliceerd en toegevoegd aan dashboard", "chart_remove_failed": "Grafiek verwijderen mislukt", "chart_removed": "Grafiek verwijderd van dashboard", "chart_restore_failed": "Grafiek herstellen mislukt", diff --git a/apps/web/locales/pt-BR.json b/apps/web/locales/pt-BR.json index 256df8a659f9..c5a76530b7aa 100644 --- a/apps/web/locales/pt-BR.json +++ b/apps/web/locales/pt-BR.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Agrupe dados por sentimento, tipo de pergunta e outras dimensões.", "edit_chart_description": "Visualize e edite a configuração do seu gráfico.", "edit_chart_title": "Editar gráfico", + "edit_chart_title_named": "Editar \"{name}\"", "emotion_value_anger": "Raiva", "emotion_value_disgust": "Nojo", "emotion_value_fear": "Medo", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Adicionar {count} gráfico(s)", + "chart_duplicate_failed": "Falha ao duplicar gráfico", + "chart_duplicated": "Gráfico duplicado e adicionado ao painel", "chart_remove_failed": "Falha ao remover gráfico", "chart_removed": "Gráfico removido do painel", "chart_restore_failed": "Falha ao restaurar gráfico", diff --git a/apps/web/locales/pt-PT.json b/apps/web/locales/pt-PT.json index 1facc92140b2..57300283838d 100644 --- a/apps/web/locales/pt-PT.json +++ b/apps/web/locales/pt-PT.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Agrupa dados por sentimento, tipo de pergunta e outras dimensões.", "edit_chart_description": "Visualize e edite a configuração do seu gráfico.", "edit_chart_title": "Editar gráfico", + "edit_chart_title_named": "Editar \"{name}\"", "emotion_value_anger": "Raiva", "emotion_value_disgust": "Repulsa", "emotion_value_fear": "Medo", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Adicionar {count} gráfico(s)", + "chart_duplicate_failed": "Falha ao duplicar gráfico", + "chart_duplicated": "Gráfico duplicado e adicionado ao painel", "chart_remove_failed": "Falha ao remover gráfico", "chart_removed": "Gráfico removido do painel", "chart_restore_failed": "Falha ao restaurar gráfico", diff --git a/apps/web/locales/ro-RO.json b/apps/web/locales/ro-RO.json index 2b117fa6c8bb..9befb981a090 100644 --- a/apps/web/locales/ro-RO.json +++ b/apps/web/locales/ro-RO.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Grupează datele după sentiment, tipul întrebării și alte dimensiuni.", "edit_chart_description": "Vezi și editează configurația graficului tău.", "edit_chart_title": "Editează graficul", + "edit_chart_title_named": "Editează \"{name}\"", "emotion_value_anger": "Furie", "emotion_value_disgust": "Dezgust", "emotion_value_fear": "Frică", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Adaugă {count} grafic(e)", + "chart_duplicate_failed": "Graficul nu a putut fi duplicat", + "chart_duplicated": "Graficul a fost duplicat și adăugat la tabloul de bord", "chart_remove_failed": "Ștergerea graficului a eșuat", "chart_removed": "Graficul a fost eliminat din tabloul de bord", "chart_restore_failed": "Restaurarea graficului a eșuat", diff --git a/apps/web/locales/ru-RU.json b/apps/web/locales/ru-RU.json index 3faaf3a26495..0b644d6d364f 100644 --- a/apps/web/locales/ru-RU.json +++ b/apps/web/locales/ru-RU.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Группируйте данные по настроению, типу вопроса и другим измерениям.", "edit_chart_description": "Просмотри и измени настройки своего графика.", "edit_chart_title": "Редактировать график", + "edit_chart_title_named": "Редактировать «{name}»", "emotion_value_anger": "Гнев", "emotion_value_disgust": "Отвращение", "emotion_value_fear": "Страх", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Добавить {count} график(ов)", + "chart_duplicate_failed": "Не удалось создать копию графика", + "chart_duplicated": "График скопирован и добавлен на дашборд", "chart_remove_failed": "Не удалось удалить диаграмму", "chart_removed": "График удалён с панели", "chart_restore_failed": "Не удалось восстановить диаграмму", diff --git a/apps/web/locales/sv-SE.json b/apps/web/locales/sv-SE.json index 8d0f2c8449d2..7b267428cdcb 100644 --- a/apps/web/locales/sv-SE.json +++ b/apps/web/locales/sv-SE.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Gruppera data efter sentiment, frågetyp och andra dimensioner.", "edit_chart_description": "Visa och redigera din diagramkonfiguration.", "edit_chart_title": "Redigera diagram", + "edit_chart_title_named": "Redigera \"{name}\"", "emotion_value_anger": "Ilska", "emotion_value_disgust": "Avsky", "emotion_value_fear": "Rädsla", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "Lägg till {count} diagram", + "chart_duplicate_failed": "Misslyckades med att duplicera diagram", + "chart_duplicated": "Diagram duplicerat och tillagt på instrumentpanelen", "chart_remove_failed": "Misslyckades med att ta bort diagram", "chart_removed": "Diagram borttaget från instrumentpanelen", "chart_restore_failed": "Misslyckades med att återställa diagram", diff --git a/apps/web/locales/tr-TR.json b/apps/web/locales/tr-TR.json index 9d6b59218066..f6ca1abe409f 100644 --- a/apps/web/locales/tr-TR.json +++ b/apps/web/locales/tr-TR.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "Verileri duygu durumu, soru türü ve diğer boyutlara göre grupla.", "edit_chart_description": "Grafik yapılandırmanı görüntüle ve düzenle.", "edit_chart_title": "Grafiği Düzenle", + "edit_chart_title_named": "\"{name}\" Düzenle", "emotion_value_anger": "Öfke", "emotion_value_disgust": "İğrenme", "emotion_value_fear": "Korku", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "{count} grafik ekle", + "chart_duplicate_failed": "Grafik kopyalanamadı", + "chart_duplicated": "Grafik kopyalandı ve panoya eklendi", "chart_remove_failed": "Grafik kaldırılamadı", "chart_removed": "Grafik gösterge panosundan kaldırıldı", "chart_restore_failed": "Grafik geri yüklenemedi", diff --git a/apps/web/locales/zh-Hans-CN.json b/apps/web/locales/zh-Hans-CN.json index 9fcb92921824..e69bd76bf035 100644 --- a/apps/web/locales/zh-Hans-CN.json +++ b/apps/web/locales/zh-Hans-CN.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "按情感、问题类型和其他维度对数据进行分组。", "edit_chart_description": "查看并编辑你的图表配置。", "edit_chart_title": "编辑图表", + "edit_chart_title_named": "编辑“{name}”", "emotion_value_anger": "愤怒", "emotion_value_disgust": "厌恶", "emotion_value_fear": "恐惧", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "添加 {count} 个图表", + "chart_duplicate_failed": "图表复制失败", + "chart_duplicated": "图表已复制并添加到仪表板", "chart_remove_failed": "删除图表失败", "chart_removed": "图表已从仪表板中移除", "chart_restore_failed": "恢复图表失败", diff --git a/apps/web/locales/zh-Hant-TW.json b/apps/web/locales/zh-Hant-TW.json index e3e01db43104..f254e4363703 100644 --- a/apps/web/locales/zh-Hant-TW.json +++ b/apps/web/locales/zh-Hant-TW.json @@ -1658,6 +1658,7 @@ "dimensions_toggle_description": "依情感、問題類型及其他維度分組資料。", "edit_chart_description": "檢視並編輯你的圖表設定。", "edit_chart_title": "編輯圖表", + "edit_chart_title_named": "編輯「{name}」", "emotion_value_anger": "憤怒", "emotion_value_disgust": "厭惡", "emotion_value_fear": "恐懼", @@ -1797,6 +1798,8 @@ }, "dashboards": { "add_count_charts": "新增 {count} 個圖表", + "chart_duplicate_failed": "圖表複製失敗", + "chart_duplicated": "圖表已複製並新增至儀表板", "chart_remove_failed": "移除圖表失敗", "chart_removed": "圖表已從儀表板移除", "chart_restore_failed": "還原圖表失敗", diff --git a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx index 33663580af5c..b448f649965b 100644 --- a/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx +++ b/apps/web/modules/ee/analysis/charts/components/cartesian-chart.tsx @@ -3,6 +3,7 @@ import { type ElementType, type ReactNode } from "react"; import { CartesianGrid, XAxis, YAxis } from "recharts"; import { formatXAxisTick } from "@/modules/ee/analysis/charts/lib/chart-utils"; +import { computeYAxis } from "@/modules/ee/analysis/charts/lib/y-axis-scale"; import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; import type { ChartConfig } from "@/modules/ui/components/chart"; import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip } from "@/modules/ui/components/chart"; @@ -26,79 +27,12 @@ export interface CartesianChartProps { /** False for measure-only charts with no real category: hides the meaningless x-axis tick and * tooltip header (which would otherwise show the fallback measure value, e.g. a stray "1"). */ hasCategoryAxis?: boolean; + /** Overrides whether the tooltip header is hidden (defaults to `!hasCategoryAxis`). Pivoted + * measure charts keep their category axis but hide the header, since each tooltip row already + * carries the measure label and a header would just repeat it. */ + tooltipHideLabel?: boolean; } -const TARGET_TICK_COUNT = 5; - -interface YAxisScale { - domain: [number, number]; - ticks: number[]; -} - -// Round a value to a "nice" number (1/2/5/10 × 10ⁿ) — the family of steps people -// expect on an axis. `round` snaps to the nearest nice number; otherwise it rounds -// up so the value fully contains the range. -const niceNum = (value: number, round: boolean): number => { - const exponent = Math.floor(Math.log10(value)); - const fraction = value / 10 ** exponent; - let niceFraction: number; - if (round) { - if (fraction < 1.5) niceFraction = 1; - else if (fraction < 3) niceFraction = 2; - else if (fraction < 7) niceFraction = 5; - else niceFraction = 10; - } else if (fraction <= 1) niceFraction = 1; - else if (fraction <= 2) niceFraction = 2; - else if (fraction <= 5) niceFraction = 5; - else niceFraction = 10; - return niceFraction * 10 ** exponent; -}; - -const computeYAxis = ( - data: TChartDataRow[], - dataKeys: string[], - zeroBaseline: boolean -): YAxisScale | undefined => { - const values: number[] = []; - for (const row of data) { - for (const key of dataKeys) { - const raw = row[key]; - if (raw === null || raw === undefined || raw === "") continue; - const num = Number(raw); - if (Number.isFinite(num)) values.push(num); - } - } - if (values.length === 0) return undefined; - - const dataMin = Math.min(...values); - const dataMax = Math.max(...values); - - // Baseline: bars and all-positive series sit on 0; only dip below zero when the - // data genuinely does, so we never draw a phantom negative axis (e.g. a -4 floor - // under a metric that bottoms out at 0). - const baselineLow = zeroBaseline ? Math.min(0, dataMin) : dataMin; - // Flat data has no range to scale, so give it a unit of headroom to render ticks. - const spanHigh = dataMax === baselineLow ? baselineLow + 1 : dataMax; - - // Snap the axis to nice bounds and derive evenly spaced round ticks, so every - // gridline lands on a labelled value. (Recharts' auto-ticks are computed from a - // raw domain and can fall outside it, drawing extra gridlines with no label.) - const step = niceNum(niceNum(spanHigh - baselineLow, false) / (TARGET_TICK_COUNT - 1), true); - const niceMin = Math.floor(baselineLow / step) * step; - const niceMax = Math.ceil(spanHigh / step) * step; - - // Clean up floating-point noise that decimal steps introduce (e.g. 0.30000000004). - const decimals = Math.max(0, -Math.floor(Math.log10(step))); - const round = (n: number) => Number(n.toFixed(decimals)); - - const ticks: number[] = []; - for (let tick = niceMin; tick <= niceMax + step / 2; tick += step) { - ticks.push(round(tick)); - } - - return { domain: [round(niceMin), round(niceMax)], ticks }; -}; - export function CartesianChart({ data, xAxisKey, @@ -112,6 +46,7 @@ export function CartesianChart({ zeroBaseline = false, xAxisTickFormatter, hasCategoryAxis = true, + tooltipHideLabel, }: Readonly) { const yScale = computeYAxis(data, dataKeys, zeroBaseline); @@ -141,7 +76,10 @@ export function CartesianChart({ /> + } cursor={tooltipCursor} // Measure-only charts (no category) have one bar per measure, so a shared tooltip would diff --git a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx index e9e76a8321af..e1e5532451af 100644 --- a/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx +++ b/apps/web/modules/ee/analysis/charts/components/chart-renderer.tsx @@ -10,15 +10,20 @@ import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, CHART_NOT_ENRICHED_COLOR, + PIVOTED_MEASURE_KEY, + PIVOTED_VALUE_KEY, formatCellValue, formatXAxisTick, + pivotMeasuresToCategories, preparePieData, } from "@/modules/ee/analysis/charts/lib/chart-utils"; import { FEEDBACK_MEASURE_IDS, formatCubeColumnHeader, + getMeasureAxisLabel, getTranslatedDimensionValueLabel, isNotEnrichedDimensionValue, + sortMeasureIdsForCategoryAxis, sortRowsByEnumDimension, } from "@/modules/ee/analysis/lib/schema-definition"; import type { TChartDataRow, TChartType } from "@/modules/ee/analysis/types/analysis"; @@ -185,6 +190,47 @@ export function ChartRenderer({ chartType, data, query }: Readonly + formatCubeColumnHeader(key, t) + ); + // Ticks use the short value label ("Very positive") — the full measure label is too + // wide, so recharts would thin the ticks and bars would lose their name. The tooltip + // keeps the full label via tooltipLabel. + const formatMeasureLabel = (value: unknown) => + getMeasureAxisLabel(typeof value === "string" ? value : "", t); + return ( + + + formatCellValue(value)} + /> + + + ); + } + // Setting `fill` on the data row (not via ) is what propagates // the per-bar colour into the tooltip payload as well as the SVG. const barData = isMultiMeasure diff --git a/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx b/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx index 2c1c0d8fc91c..402a25ce3039 100644 --- a/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx +++ b/apps/web/modules/ee/analysis/charts/components/create-chart-view.tsx @@ -67,6 +67,7 @@ export function CreateChartView({ chartLoadError, chartName, setChartName, + savedChartName, selectedChartType, handleChartTypeChange, handleChartGenerated, @@ -121,18 +122,20 @@ export function CreateChartView({ const chartType = selectedChartType ?? (isEditing ? (initialChart?.type ?? DEFAULT_CHART_TYPE) : undefined); const hasSelectedDirectory = !!selectedDirectoryId; const isAIQueryAvailable = isAIAvailable !== false; + const editChartTitle = savedChartName + ? t("workspace.analysis.charts.edit_chart_title_named", { name: savedChartName }) + : t("workspace.analysis.charts.edit_chart_title"); return ( !isOpen && handleClose()}> event.preventDefault()}> - {isEditing - ? t("workspace.analysis.charts.edit_chart_title") - : t("workspace.analysis.charts.create_chart")} + {isEditing ? editChartTitle : t("workspace.analysis.charts.create_chart")} {isEditing diff --git a/apps/web/modules/ee/analysis/charts/components/polished-tooltip.tsx b/apps/web/modules/ee/analysis/charts/components/polished-tooltip.tsx index d4ef5c75fae0..7a0b463a29cb 100644 --- a/apps/web/modules/ee/analysis/charts/components/polished-tooltip.tsx +++ b/apps/web/modules/ee/analysis/charts/components/polished-tooltip.tsx @@ -13,7 +13,7 @@ interface TooltipPayloadItem { name?: string | number; value?: unknown; color?: string; - payload?: { fill?: string }; + payload?: { fill?: string; tooltipLabel?: string }; } interface RechartsTooltipProps { @@ -48,13 +48,16 @@ export const PolishedChartTooltip = ({
{payload.map((item) => { const key = item.dataKey ?? String(item.name ?? ""); + // Rows pivoted from measures (see pivotMeasuresToCategories) carry their translated + // label on the row itself; otherwise the dataKey is a Cube column we can format. + const rowLabel = item.payload?.tooltipLabel ?? formatCubeColumnHeader(key, t); // payload.fill (per-row data.fill / pie ) wins over the Bar's series fallback. const indicatorColor = item.payload?.fill ?? item.color ?? CHART_BRAND_DARK; return (
- {formatCubeColumnHeader(key, t)} + {rowLabel}
{formatCellValue(item.value)} diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.test.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.test.ts index 83d2e2e534c4..cf2ce74f32e0 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.test.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment jsdom */ -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import type { AnalyticsResponse } from "@/modules/ee/analysis/types/analysis"; @@ -386,6 +386,71 @@ describe("useChartDialog", () => { }); }); + describe("savedChartName", () => { + test("holds the loaded chart name and stays stable while the user renames", async () => { + mockGetChartAction.mockResolvedValue({ + data: { + id: CHART_ID, + name: "Loaded Chart", + type: "bar", + query: sampleChartData.query, + feedbackDirectoryId: DIRECTORY_ID, + }, + }); + mockExecuteQueryAction.mockResolvedValue({ data: [] }); + + const { result } = renderHook(() => useChartDialog({ ...baseProps, chartId: CHART_ID })); + + await waitFor(() => { + expect(result.current.savedChartName).toBe("Loaded Chart"); + }); + expect(result.current.chartName).toBe("Loaded Chart"); + + await act(async () => { + result.current.setChartName("Renamed Chart"); + }); + + expect(result.current.chartName).toBe("Renamed Chart"); + expect(result.current.savedChartName).toBe("Loaded Chart"); + }); + + test("resets savedChartName when the dialog is closed without saving", async () => { + mockGetChartAction.mockResolvedValue({ + data: { + id: CHART_ID, + name: "Loaded Chart", + type: "bar", + query: sampleChartData.query, + feedbackDirectoryId: DIRECTORY_ID, + }, + }); + mockExecuteQueryAction.mockResolvedValue({ data: [] }); + + const onOpenChange = vi.fn(); + const { result } = renderHook(() => useChartDialog({ ...baseProps, onOpenChange, chartId: CHART_ID })); + + await waitFor(() => { + expect(result.current.savedChartName).toBe("Loaded Chart"); + }); + + await act(async () => { + result.current.handleClose(); + }); + + expect(result.current.savedChartName).toBe(""); + expect(result.current.chartName).toBe(""); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + test("keeps savedChartName empty when opening in create mode", async () => { + const { result } = renderHook(() => useChartDialog(baseProps)); + + await waitFor(() => { + expect(result.current.savedChartName).toBe(""); + }); + }); + }); + describe("handleSaveChart - validation + error paths", () => { test("toasts when chartName is empty", async () => { const { result } = renderHook(() => useChartDialog(baseProps)); diff --git a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts index 7a3af61a2805..6149aff79fcf 100644 --- a/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts +++ b/apps/web/modules/ee/analysis/charts/hooks/use-chart-dialog.ts @@ -51,6 +51,8 @@ export function useChartDialog({ const [chartData, setChartData] = useState(null); const [isAddToDashboardDialogOpen, setIsAddToDashboardDialogOpen] = useState(false); const [chartName, setChartName] = useState(""); + // Saved name of the chart being edited; unlike chartName it stays stable while the user types. + const [savedChartName, setSavedChartName] = useState(""); const [dashboards, setDashboards] = useState>([]); const [selectedDashboardId, setSelectedDashboardId] = useState(); const [isSaving, setIsSaving] = useState(false); @@ -84,6 +86,7 @@ export function useChartDialog({ if (!chartId) { setChartData(null); setChartName(""); + setSavedChartName(""); setSelectedChartType(undefined); setCurrentChartId(undefined); setSelectedDirectoryId(directories?.[0]?.id ?? null); @@ -109,6 +112,7 @@ export function useChartDialog({ if (cancelled) return; setChartName(chart.name); + setSavedChartName(chart.name); setSelectedChartType(resolveChartType(chart.type)); setCurrentChartId(chart.id); setSelectedDirectoryId(chart.feedbackDirectoryId); @@ -362,6 +366,7 @@ export function useChartDialog({ if (!isSaving) { setChartData(null); setChartName(""); + setSavedChartName(""); setSelectedChartType(undefined); setCurrentChartId(undefined); setChartLoadError(null); @@ -381,6 +386,7 @@ export function useChartDialog({ chartData, chartName, setChartName, + savedChartName, selectedChartType, initialQuery, setSelectedChartType, diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts index 44a4818dd07f..626fc1336e14 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.test.ts @@ -3,8 +3,11 @@ import { CHART_BRAND_DARK, CHART_MEASURE_COLORS, CHART_NOT_ENRICHED_COLOR, + PIVOTED_MEASURE_KEY, + PIVOTED_VALUE_KEY, formatCellValue, formatXAxisTick, + pivotMeasuresToCategories, preparePieData, resolveChartType, } from "./chart-utils"; @@ -141,6 +144,53 @@ describe("chart-utils", () => { }); }); + describe("pivotMeasuresToCategories", () => { + const label = (key: string) => `label:${key}`; + + test("pivots a single measure row into one category row per measure", () => { + const data = [{ "F.veryPositiveCount": 3, "F.negativeCount": "1" }]; + const result = pivotMeasuresToCategories(data, ["F.veryPositiveCount", "F.negativeCount"], label); + expect(result).toEqual([ + { + [PIVOTED_MEASURE_KEY]: "F.veryPositiveCount", + [PIVOTED_VALUE_KEY]: 3, + tooltipLabel: "label:F.veryPositiveCount", + fill: CHART_MEASURE_COLORS[0], + }, + { + [PIVOTED_MEASURE_KEY]: "F.negativeCount", + [PIVOTED_VALUE_KEY]: 1, + tooltipLabel: "label:F.negativeCount", + fill: CHART_MEASURE_COLORS[1], + }, + ]); + }); + + test("keeps the given measure order so bars fill the axis from the left", () => { + const data = [{ b: 2, a: 1 }]; + const result = pivotMeasuresToCategories(data, ["a", "b"], label); + expect(result.map((row) => row[PIVOTED_MEASURE_KEY])).toEqual(["a", "b"]); + }); + + test("renders missing, null, and non-numeric values as 0 so empty measures keep a slot", () => { + const data = [{ a: null, b: "n/a" }]; + const result = pivotMeasuresToCategories(data, ["a", "b", "missing"], label); + expect(result.map((row) => row[PIVOTED_VALUE_KEY])).toEqual([0, 0, 0]); + }); + + test("emits one zero-value row per measure key when data is empty", () => { + const result = pivotMeasuresToCategories([], ["a"], label); + expect(result).toHaveLength(1); + expect(result[0][PIVOTED_VALUE_KEY]).toBe(0); + }); + + test("cycles the palette when there are more measures than colors", () => { + const keys = Array.from({ length: CHART_MEASURE_COLORS.length + 1 }, (_, i) => `m${i}`); + const result = pivotMeasuresToCategories([{}], keys, label); + expect(result.at(-1)?.fill).toBe(CHART_MEASURE_COLORS[0]); + }); + }); + describe("constants", () => { test("CHART_MEASURE_COLORS has expected length", () => { expect(CHART_MEASURE_COLORS).toHaveLength(8); diff --git a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts index 94f75389983e..7b03c607e223 100644 --- a/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts +++ b/apps/web/modules/ee/analysis/charts/lib/chart-utils.ts @@ -71,6 +71,37 @@ export const preparePieData = ( return { processedData, colors }; }; +/** Category key for rows produced by {@link pivotMeasuresToCategories}. */ +export const PIVOTED_MEASURE_KEY = "measure"; +/** Value key for rows produced by {@link pivotMeasuresToCategories}. */ +export const PIVOTED_VALUE_KEY = "value"; + +/** + * Pivot a measure-only result row (one row with one column per measure) into one category row + * per measure. Rendering the raw row as N bar series leaves recharts with a single category + * band centered in the plot — a wide empty gap before the first bar. Pivoted, the measures + * become ordinary categories that fill the x-axis from the left. + * + * Missing/non-numeric values become 0 so empty measures keep a visible, hoverable slot. + * `formatLabel` supplies the translated measure label stored as `tooltipLabel` on each row. + */ +export function pivotMeasuresToCategories( + data: TChartDataRow[], + measureKeys: string[], + formatLabel: (measureKey: string) => string +): TChartDataRow[] { + const row = data[0] ?? {}; + return measureKeys.map((key, index) => { + const num = Number(row[key]); + return { + [PIVOTED_MEASURE_KEY]: key, + [PIVOTED_VALUE_KEY]: Number.isFinite(num) ? num : 0, + tooltipLabel: formatLabel(key), + fill: CHART_MEASURE_COLORS[index % CHART_MEASURE_COLORS.length], + }; + }); +} + // parseISO accepts year-only inputs (e.g. "1000" → Jan 1, 1000); require a // full YYYY-MM-DD prefix so numeric category labels aren't formatted as dates. const ISO_DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/; diff --git a/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts new file mode 100644 index 000000000000..dfea6c4d1a0c --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "vitest"; +import { computeYAxis } from "./y-axis-scale"; + +const RATING_AVG = "FeedbackRecords.ratingAverage"; +const CSAT_AVG = "FeedbackRecords.csatAverage"; +const CES_AVG = "FeedbackRecords.cesAverage"; +const NPS_AVG = "FeedbackRecords.npsAverage"; +const COUNT = "FeedbackRecords.count"; + +describe("computeYAxis", () => { + describe("fixed-scale measures pin the axis to the question scale (ENG-1796)", () => { + test("rating average 3.33 on a 1-5 question renders a 0-5 axis, not a data-driven 0-4 one", () => { + const scale = computeYAxis([{ [RATING_AVG]: 3.33 }], [RATING_AVG], true); + expect(scale).toEqual({ domain: [0, 5], ticks: [0, 1, 2, 3, 4, 5] }); + }); + + test("rating average above 7 pins to the 10 scale with even ticks", () => { + const scale = computeYAxis([{ [RATING_AVG]: 8.2 }], [RATING_AVG], true); + expect(scale).toEqual({ domain: [0, 10], ticks: [0, 2, 4, 6, 8, 10] }); + }); + + test("rating average between 5 and 7 pins to the 7 scale (smallest standard scale containing the data)", () => { + const scale = computeYAxis([{ [RATING_AVG]: 6.33 }], [RATING_AVG], true); + expect(scale).toEqual({ domain: [0, 7], ticks: [0, 1, 2, 3, 4, 5, 6, 7] }); + }); + + test("CSAT average always pins to its fixed 1-5 scale", () => { + const scale = computeYAxis([{ [CSAT_AVG]: 2.4 }], [CSAT_AVG], true); + expect(scale).toEqual({ domain: [0, 5], ticks: [0, 1, 2, 3, 4, 5] }); + }); + + test("CES average pins to 5 or 7 depending on the data", () => { + expect(computeYAxis([{ [CES_AVG]: 3 }], [CES_AVG], true)?.domain).toEqual([0, 5]); + expect(computeYAxis([{ [CES_AVG]: 6.5 }], [CES_AVG], true)?.domain).toEqual([0, 7]); + }); + + test("NPS average pins to its fixed 0-10 scale", () => { + const scale = computeYAxis([{ [NPS_AVG]: 6.33 }], [NPS_AVG], true); + expect(scale).toEqual({ domain: [0, 10], ticks: [0, 2, 4, 6, 8, 10] }); + }); + + test("pins across category rows using the series max", () => { + const data = [{ [RATING_AVG]: 1.2 }, { [RATING_AVG]: 4.8 }, { [RATING_AVG]: 3.3 }]; + expect(computeYAxis(data, [RATING_AVG], true)?.domain).toEqual([0, 5]); + }); + + test("pins line/area charts (no zero baseline flag) to the full 0-based scale too", () => { + const scale = computeYAxis([{ [RATING_AVG]: 3.33 }, { [RATING_AVG]: 4.1 }], [RATING_AVG], false); + expect(scale?.domain).toEqual([0, 5]); + }); + + test("multi-measure charts of only fixed-scale measures pin to the largest needed scale", () => { + const data = [{ [RATING_AVG]: 6.3, [CSAT_AVG]: 4 }]; + expect(computeYAxis(data, [RATING_AVG, CSAT_AVG], true)?.domain).toEqual([0, 7]); + }); + + test("a fixed-scale series with no values does not veto pinning by the other series", () => { + const data = [{ [RATING_AVG]: 3.2, [CSAT_AVG]: null }]; + expect(computeYAxis(data, [RATING_AVG, CSAT_AVG], true)?.domain).toEqual([0, 5]); + }); + }); + + describe("falls back to data-driven nice scaling", () => { + test("when the chart mixes fixed-scale and unbounded measures", () => { + const data = [{ [RATING_AVG]: 3.33, [COUNT]: 77 }]; + const scale = computeYAxis(data, [RATING_AVG, COUNT], true); + expect(scale).toEqual({ domain: [0, 80], ticks: [0, 20, 40, 60, 80] }); + }); + + test("when data exceeds the largest known scale (defensive: never clip real data)", () => { + const scale = computeYAxis([{ [RATING_AVG]: 12 }], [RATING_AVG], true); + expect(scale?.domain[1]).toBeGreaterThanOrEqual(12); + }); + + test("when a fixed-scale series unexpectedly dips negative", () => { + const scale = computeYAxis([{ [RATING_AVG]: -2 }, { [RATING_AVG]: 4 }], [RATING_AVG], false); + expect(scale?.domain[0]).toBeLessThanOrEqual(-2); + }); + + test("for unbounded measures (unchanged behavior)", () => { + const scale = computeYAxis([{ [COUNT]: 77 }], [COUNT], true); + expect(scale).toEqual({ domain: [0, 80], ticks: [0, 20, 40, 60, 80] }); + }); + + test("keeps decimal precision for small unbounded values", () => { + const scale = computeYAxis([{ [COUNT]: 0.33 }], [COUNT], false); + expect(scale?.ticks.every((tick) => Number.isFinite(tick))).toBe(true); + expect(scale?.domain[1]).toBeGreaterThanOrEqual(0.33); + }); + }); + + describe("edge cases", () => { + test("returns undefined for empty data or non-numeric values", () => { + expect(computeYAxis([], [COUNT], true)).toBeUndefined(); + expect(computeYAxis([{ [COUNT]: null }, { [COUNT]: "" }], [COUNT], true)).toBeUndefined(); + }); + + test("all-null fixed-scale data returns undefined instead of a pinned empty axis", () => { + expect(computeYAxis([{ [RATING_AVG]: null }], [RATING_AVG], true)).toBeUndefined(); + }); + + test("flat zero data still renders a usable axis", () => { + const scale = computeYAxis([{ [COUNT]: 0 }], [COUNT], true); + expect(scale?.domain[1]).toBeGreaterThan(0); + }); + + test("coerces numeric strings (Cube can return numbers as strings)", () => { + const scale = computeYAxis([{ [RATING_AVG]: "3.33" }], [RATING_AVG], true); + expect(scale?.domain).toEqual([0, 5]); + }); + }); +}); diff --git a/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.ts b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.ts new file mode 100644 index 000000000000..5e1b30600b7e --- /dev/null +++ b/apps/web/modules/ee/analysis/charts/lib/y-axis-scale.ts @@ -0,0 +1,129 @@ +import { getMeasureAxisMaxCandidates } from "@/modules/ee/analysis/lib/schema-definition"; +import type { TChartDataRow } from "@/modules/ee/analysis/types/analysis"; + +const TARGET_TICK_COUNT = 5; +// Pinned scales prefer the finest step that still keeps the axis readable; a 0-7 CES axis +// labels every integer (8 ticks) rather than overshooting the scale to hit a "nice" bound. +const MAX_PINNED_TICK_COUNT = 8; + +export interface YAxisScale { + domain: [number, number]; + ticks: number[]; +} + +// Round a value to a "nice" number (1/2/5/10 × 10ⁿ) — the family of steps people +// expect on an axis. `round` snaps to the nearest nice number; otherwise it rounds +// up so the value fully contains the range. +const niceNum = (value: number, round: boolean): number => { + const exponent = Math.floor(Math.log10(value)); + const fraction = value / 10 ** exponent; + let niceFraction: number; + if (round) { + if (fraction < 1.5) niceFraction = 1; + else if (fraction < 3) niceFraction = 2; + else if (fraction < 7) niceFraction = 5; + else niceFraction = 10; + } else if (fraction <= 1) niceFraction = 1; + else if (fraction <= 2) niceFraction = 2; + else if (fraction <= 5) niceFraction = 5; + else niceFraction = 10; + return niceFraction * 10 ** exponent; +}; + +const collectNumericValues = (data: TChartDataRow[], keys: string[]): number[] => { + const values: number[] = []; + for (const row of data) { + for (const key of keys) { + const raw = row[key]; + if (raw === null || raw === undefined || raw === "") continue; + const num = Number(raw); + if (Number.isFinite(num)) values.push(num); + } + } + return values; +}; + +/** + * Axis max for charts whose every series is a fixed-scale measure (rating/CSAT/CES/NPS + * averages): the largest of each series' smallest scale candidate that contains its data, + * so a 3.33 average on a 1-5 rating pins the axis to 5 instead of a data-driven 4 (ENG-1796). + * Returns undefined — falling back to nice scaling — when any series is not scale-pinned, + * dips negative, or exceeds its largest known scale. + */ +const computePinnedAxisMax = (data: TChartDataRow[], dataKeys: string[]): number | undefined => { + let pinnedMax = 0; + for (const key of dataKeys) { + const candidates = getMeasureAxisMaxCandidates(key); + if (!candidates || candidates.length === 0) return undefined; + const values = collectNumericValues(data, [key]); + if (values.length === 0) continue; + if (Math.min(...values) < 0) return undefined; + const keyMax = Math.max(...values); + const candidate = candidates.find((c) => c >= keyMax); + if (candidate === undefined) return undefined; + pinnedMax = Math.max(pinnedMax, candidate); + } + return pinnedMax > 0 ? pinnedMax : undefined; +}; + +// Integer ticks for a pinned [0, max] scale: the smallest 1/2/5×10ⁿ step that divides the +// scale evenly without exceeding MAX_PINNED_TICK_COUNT ticks, so every gridline lands on a +// value of the scale and the top tick is exactly the scale max (never overshoots it). +const computePinnedTicks = (max: number): number[] => { + for (let exponent = 0; 10 ** exponent <= max; exponent++) { + for (const base of [1, 2, 5]) { + const step = base * 10 ** exponent; + if (max % step === 0 && max / step + 1 <= MAX_PINNED_TICK_COUNT) { + const ticks: number[] = []; + for (let tick = 0; tick <= max; tick += step) ticks.push(tick); + return ticks; + } + } + } + return [0, max]; +}; + +export const computeYAxis = ( + data: TChartDataRow[], + dataKeys: string[], + zeroBaseline: boolean +): YAxisScale | undefined => { + const values = collectNumericValues(data, dataKeys); + if (values.length === 0) return undefined; + + const dataMin = Math.min(...values); + const dataMax = Math.max(...values); + + // Fixed-scale measures (rating/CSAT/CES/NPS averages) render against the question's full + // scale — 0 up to the pinned max — regardless of chart type, so bar heights and line + // positions read as "3.33 out of 5" rather than being stretched to a data-driven bound. + const pinnedMax = computePinnedAxisMax(data, dataKeys); + if (pinnedMax !== undefined) { + return { domain: [0, pinnedMax], ticks: computePinnedTicks(pinnedMax) }; + } + + // Baseline: bars and all-positive series sit on 0; only dip below zero when the + // data genuinely does, so we never draw a phantom negative axis (e.g. a -4 floor + // under a metric that bottoms out at 0). + const baselineLow = zeroBaseline ? Math.min(0, dataMin) : dataMin; + // Flat data has no range to scale, so give it a unit of headroom to render ticks. + const spanHigh = dataMax === baselineLow ? baselineLow + 1 : dataMax; + + // Snap the axis to nice bounds and derive evenly spaced round ticks, so every + // gridline lands on a labelled value. (Recharts' auto-ticks are computed from a + // raw domain and can fall outside it, drawing extra gridlines with no label.) + const step = niceNum(niceNum(spanHigh - baselineLow, false) / (TARGET_TICK_COUNT - 1), true); + const niceMin = Math.floor(baselineLow / step) * step; + const niceMax = Math.ceil(spanHigh / step) * step; + + // Clean up floating-point noise that decimal steps introduce (e.g. 0.30000000004). + const decimals = Math.max(0, -Math.floor(Math.log10(step))); + const round = (n: number) => Number(n.toFixed(decimals)); + + const ticks: number[] = []; + for (let tick = niceMin; tick <= niceMax + step / 2; tick += step) { + ticks.push(round(tick)); + } + + return { domain: [round(niceMin), round(niceMax)], ticks }; +}; diff --git a/apps/web/modules/ee/analysis/dashboards/actions.ts b/apps/web/modules/ee/analysis/dashboards/actions.ts index f03243ac4c18..13b48cb685bc 100644 --- a/apps/web/modules/ee/analysis/dashboards/actions.ts +++ b/apps/web/modules/ee/analysis/dashboards/actions.ts @@ -23,6 +23,7 @@ import { updateDashboard, updateWidgetLayouts, } from "./lib/dashboards"; +import { duplicateChartAndAddWidget } from "./lib/duplicate-chart-and-add-widget"; const checkDashboardsEnabled = async (organizationId: string) => { const isAllowed = await getIsDashboardsEnabled(organizationId); @@ -332,6 +333,54 @@ export const addChartToDashboardAction = authenticatedActionClient ) ); +const ZDuplicateChartAndAddWidgetAction = z.object({ + workspaceId: ZId, + dashboardId: ZId, + chartId: ZId, + layout: ZWidgetLayout.optional(), +}); + +export const duplicateChartAndAddWidgetAction = authenticatedActionClient + .inputSchema(ZDuplicateChartAndAddWidgetAction) + .action( + withAuditLogging( + "created", + "dashboardWidget", + async ({ + ctx, + parsedInput, + }: { + ctx: AuthenticatedActionClientCtx; + parsedInput: z.infer; + }) => { + const { organizationId, workspaceId } = await checkWorkspaceAccess( + ctx.user.id, + parsedInput.workspaceId, + "readWrite" + ); + await checkDashboardsEnabled(organizationId); + + const { chart, widget } = await duplicateChartAndAddWidget({ + dashboardId: parsedInput.dashboardId, + workspaceId, + chartId: parsedInput.chartId, + createdBy: ctx.user.id, + layout: parsedInput.layout, + }); + + revalidatePath(`/workspaces/${workspaceId}/dashboards/${parsedInput.dashboardId}`); + revalidatePath(`/workspaces/${workspaceId}/dashboards`); + + ctx.auditLoggingCtx.organizationId = organizationId; + ctx.auditLoggingCtx.workspaceId = workspaceId; + ctx.auditLoggingCtx.chartId = chart.id; + ctx.auditLoggingCtx.dashboardWidgetId = widget.id; + ctx.auditLoggingCtx.newObject = { chart, widget }; + return widget; + } + ) + ); + const ZRemoveWidgetFromDashboardAction = z.object({ workspaceId: ZId, dashboardId: ZId, diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx index 1ac6888e7b4c..c047ba7f05cd 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-detail-client.tsx @@ -23,6 +23,7 @@ import { GoBackButton } from "@/modules/ui/components/go-back-button"; import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper"; import { addChartToDashboardAction, + duplicateChartAndAddWidgetAction, removeWidgetFromDashboardAction, updateDashboardAction, updateWidgetLayoutsAction, @@ -129,6 +130,7 @@ const MemoizedWidgetItem = memo(function WidgetItem({ isEditing, dataPromise, onEdit, + onDuplicate, onResize, onRemove, }: Readonly<{ @@ -136,6 +138,7 @@ const MemoizedWidgetItem = memo(function WidgetItem({ isEditing: boolean; dataPromise?: Promise<{ data: TChartDataRow[]; query: TChartQuery } | { error: TDashboardWidgetError }>; onEdit?: () => void; + onDuplicate?: () => void; onResize?: () => void; onRemove?: () => void; }>) { @@ -146,6 +149,7 @@ const MemoizedWidgetItem = memo(function WidgetItem({ title={title} isEditing={isEditing} onEdit={onEdit} + onDuplicate={onDuplicate} onResize={onResize} onRemove={onRemove}> @@ -211,6 +215,28 @@ export function DashboardDetailClient({ setEditingChartId(chartId); }, []); + const handleDuplicateWidget = useCallback( + async (widget: TDashboardWidget) => { + try { + const result = await duplicateChartAndAddWidgetAction({ + workspaceId, + dashboardId: dashboard.id, + chartId: widget.chartId, + layout: widget.layout, + }); + if (!result?.data) { + toast.error(getFormattedErrorMessage(result)); + return; + } + toast.success(t("workspace.analysis.dashboards.chart_duplicated")); + startTransition(() => router.refresh()); + } catch { + toast.error(t("workspace.analysis.dashboards.chart_duplicate_failed")); + } + }, + [workspaceId, dashboard.id, router, t, startTransition] + ); + const handleUndoRemoveWidget = useCallback( async (snapshot: TDashboardWidget) => { try { @@ -418,6 +444,9 @@ export function DashboardDetailClient({ isEditing={isEditing} dataPromise={widgetDataPromises.get(widget.id)} onEdit={isReadOnly ? undefined : () => handleEditChart(widget.chartId)} + // Duplicate is hidden in edit mode: saving edit-mode drafts removes any + // widget not present in the draft, which would delete the fresh copy. + onDuplicate={isReadOnly || isEditing ? undefined : () => handleDuplicateWidget(widget)} onResize={isReadOnly ? undefined : handleEnterEditMode} onRemove={isReadOnly ? undefined : () => handleRemoveWidgetFromMenu(widget.id)} /> diff --git a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget.tsx b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget.tsx index e9f00bb677ef..1c01eceeb63f 100644 --- a/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget.tsx +++ b/apps/web/modules/ee/analysis/dashboards/components/dashboard-widget.tsx @@ -1,6 +1,6 @@ "use client"; -import { Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react"; +import { CopyIcon, Maximize2Icon, MoreVerticalIcon, SquarePenIcon, TrashIcon } from "lucide-react"; import { ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/cn"; @@ -16,6 +16,7 @@ interface DashboardWidgetProps { children: ReactNode; isEditing?: boolean; onEdit?: () => void; + onDuplicate?: () => void; onResize?: () => void; onRemove?: () => void; } @@ -25,12 +26,13 @@ export function DashboardWidget({ children, isEditing, onEdit, + onDuplicate, onResize, onRemove, }: Readonly) { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); - const hasMenuActions = Boolean(onEdit || onResize || onRemove); + const hasMenuActions = Boolean(onEdit || onDuplicate || onResize || onRemove); return (
)} + {onDuplicate && ( + { + setMenuOpen(false); + onDuplicate(); + }}> + + {t("common.duplicate")} + + )} {onResize && ( { diff --git a/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.test.ts b/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.test.ts new file mode 100644 index 000000000000..438af41d60f8 --- /dev/null +++ b/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { duplicateChart } from "@/modules/ee/analysis/charts/lib/charts"; +import { addChartToDashboard } from "./dashboards"; +import { duplicateChartAndAddWidget } from "./duplicate-chart-and-add-widget"; + +vi.mock("server-only", () => ({})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + dashboard: { + findFirst: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/utils/validate", () => ({ + validateInputs: vi.fn(), +})); + +vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({ + duplicateChart: vi.fn(), +})); + +vi.mock("./dashboards", () => ({ + addChartToDashboard: vi.fn(), +})); + +const mockDashboardId = "dashboard-abc-123"; +const mockWorkspaceId = "workspace-abc-123"; +const mockUserId = "user-abc-123"; +const mockChartId = "chart-abc-123"; +const mockLayout = { x: 0, y: 0, w: 4, h: 3 }; + +const mockDuplicatedChart = { + id: "chart-copy-123", + name: "Test Chart (copy)", + type: "bar", + query: { measures: ["Feedback.count"] }, + config: {}, + feedbackDirectoryId: "directory-abc-123", + createdAt: new Date("2025-01-01"), + updatedAt: new Date("2025-01-01"), +}; + +const mockWidget = { + id: "widget-new-123", + dashboardId: mockDashboardId, + chartId: mockDuplicatedChart.id, + layout: mockLayout, + order: 1, +}; + +const makePrismaError = (code: string) => + new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" }); + +describe("duplicateChartAndAddWidget", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("duplicates the chart and adds it as a widget on the same dashboard", async () => { + vi.mocked(prisma.dashboard.findFirst).mockResolvedValue({ id: mockDashboardId } as any); + vi.mocked(duplicateChart).mockResolvedValue(mockDuplicatedChart as any); + vi.mocked(addChartToDashboard).mockResolvedValue(mockWidget as any); + + const result = await duplicateChartAndAddWidget({ + dashboardId: mockDashboardId, + workspaceId: mockWorkspaceId, + chartId: mockChartId, + createdBy: mockUserId, + layout: mockLayout, + }); + + expect(result).toEqual({ chart: mockDuplicatedChart, widget: mockWidget }); + expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({ + where: { id: mockDashboardId, workspaceId: mockWorkspaceId }, + select: { id: true }, + }); + expect(duplicateChart).toHaveBeenCalledWith(mockChartId, mockWorkspaceId, mockUserId); + expect(addChartToDashboard).toHaveBeenCalledWith({ + dashboardId: mockDashboardId, + chartId: mockDuplicatedChart.id, + workspaceId: mockWorkspaceId, + layout: mockLayout, + }); + }); + + test("passes an undefined layout through so the widget gets the chart-type default", async () => { + vi.mocked(prisma.dashboard.findFirst).mockResolvedValue({ id: mockDashboardId } as any); + vi.mocked(duplicateChart).mockResolvedValue(mockDuplicatedChart as any); + vi.mocked(addChartToDashboard).mockResolvedValue(mockWidget as any); + + await duplicateChartAndAddWidget({ + dashboardId: mockDashboardId, + workspaceId: mockWorkspaceId, + chartId: mockChartId, + createdBy: mockUserId, + }); + + expect(addChartToDashboard).toHaveBeenCalledWith({ + dashboardId: mockDashboardId, + chartId: mockDuplicatedChart.id, + workspaceId: mockWorkspaceId, + layout: undefined, + }); + }); + + test("throws ResourceNotFoundError and does not duplicate when the dashboard is not in the workspace", async () => { + vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null); + + await expect( + duplicateChartAndAddWidget({ + dashboardId: mockDashboardId, + workspaceId: mockWorkspaceId, + chartId: mockChartId, + createdBy: mockUserId, + }) + ).rejects.toMatchObject({ + name: "ResourceNotFoundError", + resourceType: "Dashboard", + resourceId: mockDashboardId, + }); + expect(duplicateChart).not.toHaveBeenCalled(); + expect(addChartToDashboard).not.toHaveBeenCalled(); + }); + + test("propagates duplicateChart errors without adding a widget", async () => { + vi.mocked(prisma.dashboard.findFirst).mockResolvedValue({ id: mockDashboardId } as any); + vi.mocked(duplicateChart).mockRejectedValue( + Object.assign(new Error("Chart not found"), { name: "ResourceNotFoundError" }) + ); + + await expect( + duplicateChartAndAddWidget({ + dashboardId: mockDashboardId, + workspaceId: mockWorkspaceId, + chartId: mockChartId, + createdBy: mockUserId, + }) + ).rejects.toMatchObject({ name: "ResourceNotFoundError" }); + expect(addChartToDashboard).not.toHaveBeenCalled(); + }); + + test("wraps Prisma errors from the dashboard lookup in DatabaseError", async () => { + vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999")); + + await expect( + duplicateChartAndAddWidget({ + dashboardId: mockDashboardId, + workspaceId: mockWorkspaceId, + chartId: mockChartId, + createdBy: mockUserId, + }) + ).rejects.toMatchObject({ name: "DatabaseError" }); + expect(duplicateChart).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.ts b/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.ts new file mode 100644 index 000000000000..cab6776a542f --- /dev/null +++ b/apps/web/modules/ee/analysis/dashboards/lib/duplicate-chart-and-add-widget.ts @@ -0,0 +1,63 @@ +import "server-only"; +import { prisma } from "@formbricks/database"; +import { Prisma } from "@formbricks/database/prisma"; +import { TWidgetLayout, ZWidgetLayout } from "@formbricks/types/analysis"; +import { ZId } from "@formbricks/types/common"; +import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; +import { validateInputs } from "@/lib/utils/validate"; +import { duplicateChart } from "@/modules/ee/analysis/charts/lib/charts"; +import { addChartToDashboard } from "./dashboards"; + +/** + * Deep-copies a chart (new UUID, "(copy)" name suffix, copied query/config) and adds the + * new chart as a widget on the given dashboard. The dashboard is verified first so a + * missing dashboard does not leave an orphaned chart copy behind. + */ +export const duplicateChartAndAddWidget = async ({ + dashboardId, + workspaceId, + chartId, + createdBy, + layout, +}: { + dashboardId: string; + workspaceId: string; + chartId: string; + createdBy: string; + layout?: TWidgetLayout; +}) => { + validateInputs( + [dashboardId, ZId], + [workspaceId, ZId], + [chartId, ZId], + [createdBy, ZId], + [layout, ZWidgetLayout.optional()] + ); + + let dashboard; + try { + dashboard = await prisma.dashboard.findFirst({ + where: { id: dashboardId, workspaceId }, + select: { id: true }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + throw new DatabaseError(error.message); + } + throw error; + } + + if (!dashboard) { + throw new ResourceNotFoundError("Dashboard", dashboardId); + } + + const chart = await duplicateChart(chartId, workspaceId, createdBy); + const widget = await addChartToDashboard({ + dashboardId, + chartId: chart.id, + workspaceId, + layout, + }); + + return { chart, widget }; +}; diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts index a950d02780f5..076bd712ce27 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.test.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.test.ts @@ -13,11 +13,13 @@ import { formatCubeColumnHeader, getFieldById, getFilterOperatorsForType, + getMeasureAxisLabel, getTranslatedDimensionValueLabel, getTranslatedFieldLabel, isEnrichmentDimensionId, isNotEnrichedDimensionValue, isSelectableValueDimension, + sortMeasureIdsForCategoryAxis, sortRowsByEnumDimension, } from "./schema-definition"; @@ -306,6 +308,81 @@ describe("schema-definition", () => { }); }); + describe("sortMeasureIdsForCategoryAxis", () => { + test("sorts sentiment count measures into the sentiment scale order", () => { + const pickerOrder = [ + "FeedbackRecords.veryPositiveCount", + "FeedbackRecords.positiveCount", + "FeedbackRecords.neutralCount", + "FeedbackRecords.negativeCount", + "FeedbackRecords.veryNegativeCount", + "FeedbackRecords.mixedCount", + ]; + expect(sortMeasureIdsForCategoryAxis(pickerOrder)).toEqual([ + "FeedbackRecords.veryNegativeCount", + "FeedbackRecords.negativeCount", + "FeedbackRecords.neutralCount", + "FeedbackRecords.positiveCount", + "FeedbackRecords.veryPositiveCount", + "FeedbackRecords.mixedCount", + ]); + }); + + test("keeps non-sentiment measures in their relative order, after sentiment ones", () => { + const ids = [ + "FeedbackRecords.joyCount", + "FeedbackRecords.positiveCount", + "FeedbackRecords.count", + "FeedbackRecords.veryNegativeCount", + ]; + expect(sortMeasureIdsForCategoryAxis(ids)).toEqual([ + "FeedbackRecords.veryNegativeCount", + "FeedbackRecords.positiveCount", + "FeedbackRecords.joyCount", + "FeedbackRecords.count", + ]); + }); + + test("does not mutate the input array", () => { + const ids = ["FeedbackRecords.positiveCount", "FeedbackRecords.veryNegativeCount"]; + sortMeasureIdsForCategoryAxis(ids); + expect(ids).toEqual(["FeedbackRecords.positiveCount", "FeedbackRecords.veryNegativeCount"]); + }); + }); + + describe("getMeasureAxisLabel", () => { + const t = ((key: string) => key) as TFunction; + + test("labels sentiment count measures with their short value label", () => { + expect(getMeasureAxisLabel("FeedbackRecords.veryPositiveCount", t)).toBe( + "workspace.analysis.charts.sentiment_value_very_positive" + ); + expect(getMeasureAxisLabel("FeedbackRecords.mixedCount", t)).toBe( + "workspace.analysis.charts.sentiment_value_mixed" + ); + }); + + test("labels emotion count measures with their short value label", () => { + expect(getMeasureAxisLabel("FeedbackRecords.joyCount", t)).toBe( + "workspace.analysis.charts.emotion_value_joy" + ); + expect(getMeasureAxisLabel("FeedbackRecords.disgustCount", t)).toBe( + "workspace.analysis.charts.emotion_value_disgust" + ); + }); + + test("falls back to the full column header for other measures", () => { + expect(getMeasureAxisLabel("FeedbackRecords.count", t)).toBe( + "workspace.analysis.charts.field_label_count" + ); + // promoterCount is an NPS measure, not a sentiment/emotion enum measure + expect(getMeasureAxisLabel("FeedbackRecords.promoterCount", t)).toBe( + "workspace.analysis.charts.field_label_promoter_count" + ); + expect(getMeasureAxisLabel("Some.unknownKey", t)).toBe("Unknown Key"); + }); + }); + describe("isNotEnrichedDimensionValue", () => { test("true only for empty values on sentiment/emotions dimensions", () => { expect(isNotEnrichedDimensionValue("FeedbackRecords.sentiment", "")).toBe(true); diff --git a/apps/web/modules/ee/analysis/lib/schema-definition.ts b/apps/web/modules/ee/analysis/lib/schema-definition.ts index 0590a1012c53..823223f1830f 100644 --- a/apps/web/modules/ee/analysis/lib/schema-definition.ts +++ b/apps/web/modules/ee/analysis/lib/schema-definition.ts @@ -23,6 +23,14 @@ export interface MeasureDefinition { type: "count" | "number"; group: TMeasureGroup; description?: string; + /** + * Candidate Y-axis maxima (ascending) for measures answered on a bounded rating scale. Charts pin + * the axis to the smallest candidate that contains the data (e.g. an average of 3.33 on a 1-5 + * rating renders on a 0-5 axis, not a data-driven 0-4 one), so the bar height reads against the + * question's actual scale. The question's true scale is not stored on feedback records (see + * ratingAverage below), so multi-candidate lists are a best-effort inference from the data max. + */ + axisMaxCandidates?: readonly number[]; } /** @@ -247,6 +255,7 @@ export const FEEDBACK_FIELDS = { type: "number", group: "average", description: "Average NPS rating (0-10)", + axisMaxCandidates: [10], }, { id: "FeedbackRecords.promoterCount", @@ -282,6 +291,7 @@ export const FEEDBACK_FIELDS = { type: "number", group: "average", description: "Average CSAT rating (1-5)", + axisMaxCandidates: [5], }, { id: "FeedbackRecords.csatSatisfiedCount", @@ -317,6 +327,7 @@ export const FEEDBACK_FIELDS = { type: "number", group: "average", description: "Average CES rating (scale is 1-5 or 1-7 depending on the question)", + axisMaxCandidates: [5, 7], }, { id: "FeedbackRecords.cesCount", @@ -331,6 +342,11 @@ export const FEEDBACK_FIELDS = { type: "number", group: "average", description: "Average rating value (scale depends on the question, e.g. 1-5 or 1-10)", + // Feedback records don't store the question's rating scale (transform.ts writes only + // value_number), so pin to the smallest standard rating scale that contains the data + // (ENG-1796). A 1-10 question whose averages stay <= 7 will render on a 0-7 axis until + // the scale is ingested as record metadata - see the measure docs on axisMaxCandidates. + axisMaxCandidates: [5, 7, 10], }, { id: "FeedbackRecords.ratingCount", @@ -354,6 +370,11 @@ export const FEEDBACK_FIELDS = { export const FEEDBACK_MEASURE_IDS: string[] = FEEDBACK_FIELDS.measures.map((m) => m.id); +/** Candidate Y-axis maxima for fixed-scale measures (see MeasureDefinition.axisMaxCandidates), + * or undefined for measures whose axis should stay data-driven. */ +export const getMeasureAxisMaxCandidates = (measureId: string): readonly number[] | undefined => + FEEDBACK_FIELDS.measures.find((m) => m.id === measureId)?.axisMaxCandidates; + export const FEEDBACK_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions.map((d) => d.id); export const FEEDBACK_TIME_DIMENSION_IDS: string[] = FEEDBACK_FIELDS.dimensions @@ -437,6 +458,46 @@ export function getTranslatedDimensionValueLabel( return undefined; } +// Sentiment count measure ids in the sentiment *scale* order (very_negative → very_positive, +// mixed last) — the order the sentiment dimension axis uses. +const SENTIMENT_COUNT_MEASURE_IDS_BY_SCALE: string[] = SENTIMENT_VALUE_ORDER.map( + (value) => `FeedbackRecords.${toCountMeasureId(value)}Count` +); + +/** + * Sort measure ids for a per-measure category axis (measure-only bar charts, where each + * measure is its own bar). Sentiment count measures follow the sentiment scale order so the + * pivoted chart reads in the same direction (and with the same per-slot colors) as a chart + * grouped by the sentiment dimension — not the positive-first SENTIMENT_MEASURE_ORDER used + * for series/legend lists. Other measures keep their relative order after them. + */ +export function sortMeasureIdsForCategoryAxis(measureIds: string[]): string[] { + const rank = (id: string): number => { + const index = SENTIMENT_COUNT_MEASURE_IDS_BY_SCALE.indexOf(id); + return index === -1 ? Number.MAX_SAFE_INTEGER : index; + }; + return [...measureIds].sort((a, b) => rank(a) - rank(b)); +} + +/** + * Short x-axis label for a per-measure category axis (measure-only bar charts, where each + * measure is its own bar). Sentiment/emotion count measures reuse their enum value label + * ("Very positive") — the full measure label ("Sentiment: Very positive") is too wide for + * ticks, so recharts drops the overlapping ones and leaves bars unlabelled. Other measures + * fall back to their full column header. + */ +export function getMeasureAxisLabel(measureId: string, t: TFunction): string { + const sentiment = SENTIMENT_MEASURE_ORDER.find( + (value) => `FeedbackRecords.${toCountMeasureId(value)}Count` === measureId + ); + const emotion = EMOTION_MEASURE_ORDER.find((value) => `FeedbackRecords.${value}Count` === measureId); + return ( + (sentiment ? getTranslatedSentimentValueLabel(sentiment, t) : undefined) ?? + (emotion ? getTranslatedEmotionValueLabel(emotion, t) : undefined) ?? + formatCubeColumnHeader(measureId, t) + ); +} + /** * Sort chart rows into the sentiment scale order (very_negative → very_positive, mixed * last) when the x-axis is the sentiment dimension. Unknown values keep their relative diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx index 7f2ed3efde7d..31a4232cfd0c 100644 --- a/apps/web/modules/ee/billing/components/pricing-table.tsx +++ b/apps/web/modules/ee/billing/components/pricing-table.tsx @@ -42,6 +42,17 @@ import { UsageCard } from "./usage-card"; const BILLING_CONFIRMATION_ORGANIZATION_ID_KEY = "billingConfirmationOrganizationId"; const BILLING_PENDING_UPGRADE_PLAN_KEY = "billingPendingUpgradePlan"; const BILLING_PENDING_UPGRADE_INTERVAL_KEY = "billingPendingUpgradeInterval"; +// Hands the post-upgrade success toast across the full reload the finalize path does to render the +// synced plan. +const BILLING_UPGRADE_RESULT_KEY = "billingUpgradeResult"; + +// After checkout the plan lands in our DB via an async Stripe webhook (not instant). Poll a Stripe +// force-sync until the plan reflects, so we can render the new plan without a manual refresh. Bounded +// so a genuinely stuck upgrade doesn't spin forever. +const UPGRADE_SYNC_POLL_INTERVAL_MS = 1500; +const UPGRADE_SYNC_POLL_TIMEOUT_MS = 45000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); // Stripe.js is loaded lazily and memoized across renders (one publishable key per deploy). let stripeJsPromise: Promise | null = null; @@ -350,6 +361,28 @@ export const PricingTable = ({ }; }; + // Show the success toast that the finalize path stashed before its full reload (see finish()). + useEffect(() => { + if (globalThis.window === undefined) { + return; + } + const raw = globalThis.window.sessionStorage.getItem(BILLING_UPGRADE_RESULT_KEY); + if (!raw) { + return; + } + globalThis.window.sessionStorage.removeItem(BILLING_UPGRADE_RESULT_KEY); + try { + const { plan } = JSON.parse(raw) as { plan: TStandardPlan }; + toast.success( + t("workspace.settings.billing.upgrade_checkout_success", { + plan: getCurrentCloudPlanLabel(plan ?? "pro", t), + }) + ); + } catch { + // Malformed handoff payload — nothing to show. + } + }, [t]); + useEffect(() => { if (searchParams.get("checkout_success") !== "1") { return; @@ -376,37 +409,56 @@ export const PricingTable = ({ "hobby" > | null; + // upgradeDriveRef guarantees this runs once; deliberately no `cancelled` cleanup flag — React + // StrictMode's mount→unmount→mount in dev would otherwise cancel the real run before it reaches + // the reload, leaving the page stale (the bug looks identical to no fix at all). upgradeDriveRef.current = true; - let cancelled = false; + // Loading toast at the top of this first post-checkout page, shown while we poll for the plan. const toastId = toast.loading(t("workspace.settings.billing.upgrade_checkout_pending")); + const billingUrl = `/organizations/${organizationId}/settings/billing`; + + // Once the plan is confirmed in our DB, a full reload is the only reliable way to render it + // (router.refresh() did not consistently refetch the billing snapshot). Hand the toast across it. + const reloadWithToast = (plan: Exclude | null) => { + if (globalThis.window === undefined) return; + globalThis.window.sessionStorage.setItem( + BILLING_UPGRADE_RESULT_KEY, + JSON.stringify({ plan: plan ?? "pro" }) + ); + globalThis.window.location.replace(billingUrl); + }; - const finish = ( - kind: "success" | "error", - plan: Exclude | null, - message?: string - ) => { + // Terminal state without a reload (error, or the poll timed out): clean the URL, dismiss the + // loading toast, surface a message. The webhook will still land the plan; the user can refresh. + const settleWithoutReload = (message: string) => { + clearUpgradeIntent(); toast.dismiss(toastId); - if (kind === "success") { - toast.success( - t("workspace.settings.billing.upgrade_checkout_success", { - plan: getCurrentCloudPlanLabel(plan ?? "pro", t), - }) - ); - } else { - toast.error(message ?? t("common.something_went_wrong_please_try_again")); + toast.error(message); + if (globalThis.window !== undefined) { + globalThis.window.history.replaceState(null, "", billingUrl); } - clearUpgradeIntent(); - router.replace(`/organizations/${organizationId}/settings/billing`); router.refresh(); }; + // Poll a Stripe force-sync until the plan reflects in our DB (waitForBillingPlanAction syncs + + // invalidates the cache each call). Doesn't depend on the async webhook. + const pollUntilPlanApplied = async (targetPlan: Exclude): Promise => { + const deadline = Date.now() + UPGRADE_SYNC_POLL_TIMEOUT_MS; + for (;;) { + const waitResult = await waitForBillingPlanAction({ organizationId, targetPlan }); + if (waitResult?.data?.plan === targetPlan) return true; + if (Date.now() >= deadline) return false; + await sleep(UPGRADE_SYNC_POLL_INTERVAL_MS); + } + }; + const run = async () => { - // Finalize attaches the card and applies the upgrade in one call, so it never races the webhook. + // Finalize attaches the saved card and applies the upgrade; the plan then reflects in our DB + // asynchronously, which is what pollUntilPlanApplied waits for below. const response = await finalizeSetupCheckoutUpgradeAction({ organizationId, checkoutSessionId }); - if (cancelled) return; if (response?.serverError) { - finish("error", pendingPlan, getActionErrorMessage(response.serverError, t)); + settleWithoutReload(getActionErrorMessage(response.serverError, t)); return; } @@ -415,26 +467,27 @@ export const PricingTable = ({ if (response?.data) { const settled = await settleUpgradeConfirmation(response.data); - if (cancelled) return; if (!settled.applied) { - finish("error", resolvedPlan, settled.message ?? undefined); + settleWithoutReload(settled.message ?? t("common.something_went_wrong_please_try_again")); return; } } - if (resolvedPlan) { - await waitForBillingPlanAction({ organizationId, targetPlan: resolvedPlan }); - if (cancelled) return; + if (!resolvedPlan) { + // No target to verify against — best effort: reload so any applied change renders. + reloadWithToast(resolvedPlan); + return; + } + + if (await pollUntilPlanApplied(resolvedPlan)) { + reloadWithToast(resolvedPlan); + } else { + // Poll window elapsed without the plan reflecting; the webhook will still catch up. + settleWithoutReload(t("workspace.settings.billing.upgrade_checkout_pending")); } - finish("success", resolvedPlan); }; void run(); - - return () => { - cancelled = true; - toast.dismiss(toastId); - }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchParams, router, t, organizationId]); diff --git a/apps/web/modules/ee/billing/lib/organization-billing.ts b/apps/web/modules/ee/billing/lib/organization-billing.ts index 209a78555fd1..690ee694a0ec 100644 --- a/apps/web/modules/ee/billing/lib/organization-billing.ts +++ b/apps/web/modules/ee/billing/lib/organization-billing.ts @@ -590,7 +590,10 @@ export const createPaidPlanCheckoutSession = async (input: { address: "auto", name: "auto", }, - success_url: `${WEBAPP_URL}/billing-confirmation?organizationId=${input.organizationId}&checkout_success=1`, + // Carry the purchased plan so the confirmation page can force a Stripe sync (the read-through + // sync only refreshes a >5min-stale snapshot, so a fresh post-checkout snapshot would otherwise + // keep serving the old plan) before returning to billing. + success_url: `${WEBAPP_URL}/billing-confirmation?organizationId=${input.organizationId}&checkout_success=1&plan=${input.plan}`, cancel_url: `${WEBAPP_URL}/organizations/${input.organizationId}/settings/billing`, metadata: { organizationId: input.organizationId, diff --git a/apps/web/modules/ui/components/dialog/index.tsx b/apps/web/modules/ui/components/dialog/index.tsx index b81e236db879..902bf3ba44de 100644 --- a/apps/web/modules/ui/components/dialog/index.tsx +++ b/apps/web/modules/ui/components/dialog/index.tsx @@ -34,6 +34,8 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; interface DialogContentProps { hideCloseButton?: boolean; disableCloseOnOutsideClick?: boolean; + /** Keep Escape closing the dialog even when disableCloseOnOutsideClick is set. */ + closeOnEscape?: boolean; width?: "default" | "wide" | "full" | "narrow"; unconstrained?: boolean; } @@ -61,6 +63,7 @@ const DialogContent = React.forwardRef< children, hideCloseButton, disableCloseOnOutsideClick, + closeOnEscape, width = "default", unconstrained = false, ...props @@ -81,7 +84,9 @@ const DialogContent = React.forwardRef< className )} onPointerDownOutside={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined} - onEscapeKeyDown={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined} + onEscapeKeyDown={ + disableCloseOnOutsideClick && !closeOnEscape ? (e) => e.preventDefault() : undefined + } {...props}> {children} {!hideCloseButton && (