From 63833f46c1b4418123d4782f471613369b1e8b66 Mon Sep 17 00:00:00 2001
From: github-automation-metabase
 <166700802+github-automation-metabase@users.noreply.github.com>
Date: Tue, 10 Dec 2024 09:27:50 -0500
Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20backported=20"feat(sdk):=20combi?=
 =?UTF-8?q?ne=20title=20props=20in=20interactive=20question"=20(#51066)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Co-authored-by: Phoomparin Mano <poom@metabase.com>
---
 docs/embedding/sdk/questions.md               |  7 ++-
 .../private/InteractiveAdHocQuestion.tsx      |  8 ++--
 .../InteractiveQuestionResult.tsx             | 30 ++++++++++---
 .../EditableDashboard.tsx                     |  2 +-
 .../InteractiveDashboard.tsx                  |  2 +-
 .../InteractiveQuestion.tsx                   |  6 +--
 .../InteractiveQuestion.unit.spec.tsx         | 43 +++++++++++++++++--
 .../src/embedding-sdk/types/question.ts       |  9 ++++
 8 files changed, 84 insertions(+), 23 deletions(-)

diff --git a/docs/embedding/sdk/questions.md b/docs/embedding/sdk/questions.md
index 43b1f722ea1..1e5b5f0e5d1 100644
--- a/docs/embedding/sdk/questions.md
+++ b/docs/embedding/sdk/questions.md
@@ -69,19 +69,18 @@ export default function App() {
 ## Question props
 
 | Prop                  | Type                                                                 | Description                                                                                                                                                                                                                                                                                                        |
-|-----------------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
 | questionId            | number or string                                                     | (required) The ID of the question. This is either:<br>- The numerical ID when accessing a question link, e.g., `http://localhost:3000/question/1-my-question` where the ID is `1`.<br>- The `entity_id` key of the question object. You can find a question's entity ID in the info panel when viewing a question. |
 | plugins               | `{ mapQuestionClickActions: Function }` or null                      | Additional mapper function to override or add drill-down menu.                                                                                                                                                                                                                                                     |
 | height                | number or string                                                     | (optional) A number or string specifying a CSS size value that specifies the height of the component                                                                                                                                                                                                               |
 | entityTypeFilter      | string array; options include "table", "question", "model", "metric" | (optional) An array that specifies which entity types are available in the data picker                                                                                                                                                                                                                             |
 | isSaveEnabled         | boolean                                                              | (optional) Whether people can save the question.                                                                                                                                                                                                                                                                   |
 | withResetButton       | boolean                                                              | (optional, default: `true`) Determines whether a reset button is displayed. Only relevant when using the default layout                                                                                                                                                                                            |
-| withTitle             | boolean                                                              | (optional, default: `false`) Determines whether the question title is displayed. Only relevant when using the default layout.                                                                                                                                                                                      |
-| customTitle           | string or undefined                                                  | (optional) Allows a custom title to be displayed instead of the default question title. Only relevant when using the default layout.                                                                                                                                                                               |
 | withChartTypeSelector | boolean                                                              | (optional, default: `true`) Determines whether the chart type selector is shown. Only relevant when using the default layout.                                                                                                                                                                                      |
+| title                 | boolean or string or `ReactNode` or `() => ReactNode`                | (optional) Determines whether the question title is displayed, and allows a custom title to be displayed instead of the default question title. Shown by default. Only Only applicable to interactive questions when using the default layout.                                                                     |
 | onBeforeSave          | `() => void`                                                         | (optional) A callback function that triggers before saving. Only relevant when `isSaveEnabled = true`.                                                                                                                                                                                                             |
 | onSave                | `() => void`                                                         | (optional) A callback function that triggers when a user saves the question. Only relevant when `isSaveEnabled = true`.                                                                                                                                                                                            |
-| saveToCollectionId    | number                                                               | (optional) The target collection to save the question to. This will hide the collection picker from the save modal. Only applicable to static questions.                                                                                                                                                           |
+| saveToCollectionId    | number                                                               | (optional) The target collection to save the question to. This will hide the collection picker from the save modal. Only applicable to interactive questions.                                                                                                                                                      |
 
 ## Customizing interactive questions
 
diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveAdHocQuestion.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveAdHocQuestion.tsx
index 9840058a490..5b626e9692c 100644
--- a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveAdHocQuestion.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveAdHocQuestion.tsx
@@ -1,6 +1,7 @@
 import { type ReactNode, useMemo } from "react";
 
 import type { SdkPluginsConfig } from "embedding-sdk";
+import type { SdkQuestionTitleProps } from "embedding-sdk/types/question";
 
 import {
   InteractiveQuestionProviderWithLocation,
@@ -11,8 +12,7 @@ import { InteractiveQuestionResult } from "./InteractiveQuestionResult";
 interface InteractiveAdHocQuestionProps {
   questionPath: string; // route path to load a question, e.g. /question/140-best-selling-products - for saved, or /question/xxxxxxx for ad-hoc encoded question config
   onNavigateBack: () => void;
-
-  withTitle?: boolean;
+  title: SdkQuestionTitleProps;
   height?: number;
   plugins?: SdkPluginsConfig;
   children?: ReactNode;
@@ -21,7 +21,7 @@ interface InteractiveAdHocQuestionProps {
 export const InteractiveAdHocQuestion = ({
   questionPath,
   onNavigateBack,
-  withTitle = true,
+  title = true,
   height,
   plugins,
   children,
@@ -41,7 +41,7 @@ export const InteractiveAdHocQuestion = ({
       {children ?? (
         <InteractiveQuestionResult
           height={height}
-          withTitle={withTitle}
+          title={title}
           withChartTypeSelector
         />
       )}
diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestionResult/InteractiveQuestionResult.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestionResult/InteractiveQuestionResult.tsx
index d191b8242b7..5d73142d72e 100644
--- a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestionResult/InteractiveQuestionResult.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestionResult/InteractiveQuestionResult.tsx
@@ -1,6 +1,6 @@
 import { useDisclosure } from "@mantine/hooks";
 import cx from "classnames";
-import { type ReactElement, type ReactNode, useState } from "react";
+import { type ReactElement, type ReactNode, useMemo, useState } from "react";
 import { match } from "ts-pattern";
 import { t } from "ttag";
 
@@ -8,6 +8,7 @@ import {
   SdkError,
   SdkLoader,
 } from "embedding-sdk/components/private/PublicComponentWrapper";
+import type { SdkQuestionTitleProps } from "embedding-sdk/types/question";
 import { SaveQuestionModal } from "metabase/containers/SaveQuestionModal";
 import { Box, Button, Group, Icon } from "metabase/ui";
 
@@ -21,9 +22,8 @@ import { useInteractiveQuestionContext } from "../InteractiveQuestion/context";
 import InteractiveQuestionS from "./InteractiveQuestionResult.module.css";
 
 export interface InteractiveQuestionResultProps {
+  title?: SdkQuestionTitleProps;
   withResetButton?: boolean;
-  withTitle?: boolean;
-  customTitle?: ReactNode;
   withChartTypeSelector?: boolean;
 }
 
@@ -55,8 +55,7 @@ export const InteractiveQuestionResult = ({
   width,
   className,
   style,
-  withTitle,
-  customTitle,
+  title,
   withResetButton,
   withChartTypeSelector,
 }: InteractiveQuestionResultProps & FlexibleSizeProps): ReactElement => {
@@ -83,6 +82,25 @@ export const InteractiveQuestionResult = ({
   // When visualizing a question for the first time, there is no query result yet.
   const isQueryResultLoading = question && !queryResults;
 
+  const questionTitleElement: ReactNode = useMemo(() => {
+    if (title === false) {
+      return null;
+    }
+
+    if (title === undefined || title === true) {
+      return <InteractiveQuestion.Title />;
+    }
+
+    if (typeof title === "function") {
+      const CustomTitle = title;
+
+      // TODO: pass in question={question} once we have the public-facing question type (metabase#50487)
+      return <CustomTitle />;
+    }
+
+    return title;
+  }, [title]);
+
   if (isQuestionLoading || isQueryResultLoading) {
     return <SdkLoader />;
   }
@@ -100,7 +118,7 @@ export const InteractiveQuestionResult = ({
     >
       <Group className={InteractiveQuestionS.TopBar} position="apart" p="md">
         <InteractiveQuestion.BackButton />
-        {withTitle && (customTitle ?? <InteractiveQuestion.Title />)}
+        {questionTitleElement}
         <Group spacing="xs">
           {withResetButton && <InteractiveQuestion.ResetButton />}
           <InteractiveQuestion.FilterButton
diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.tsx
index 7b82ac13865..bb00415bdaf 100644
--- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/EditableDashboard.tsx
@@ -72,7 +72,7 @@ export const EditableDashboard = ({
       {adhocQuestionUrl ? (
         <InteractiveAdHocQuestion
           questionPath={adhocQuestionUrl}
-          withTitle
+          title={true}
           height={drillThroughQuestionHeight}
           plugins={plugins}
           onNavigateBack={onNavigateBackToDashboard}
diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.tsx
index aeebc991e4e..0b555d8f32f 100644
--- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveDashboard/InteractiveDashboard.tsx
@@ -85,7 +85,7 @@ const InteractiveDashboardInner = ({
       {adhocQuestionUrl ? (
         <InteractiveAdHocQuestion
           questionPath={adhocQuestionUrl}
-          withTitle={withTitle}
+          title={withTitle}
           height={drillThroughQuestionHeight}
           plugins={plugins}
           onNavigateBack={onNavigateBackToDashboard}
diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx
index b6e9ca90cc3..4bf5229eda9 100644
--- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.tsx
@@ -46,8 +46,7 @@ export type InteractiveQuestionProps = PropsWithChildren<{
 export const _InteractiveQuestion = ({
   questionId,
   withResetButton = true,
-  withTitle = false,
-  customTitle,
+  title,
   plugins,
   height,
   width,
@@ -78,9 +77,8 @@ export const _InteractiveQuestion = ({
         width={width}
         className={className}
         style={style}
-        customTitle={customTitle}
+        title={title}
         withResetButton={withResetButton}
-        withTitle={withTitle}
         withChartTypeSelector={withChartTypeSelector}
       />
     )}
diff --git a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.unit.spec.tsx
index 6eb3a7c61bc..a916d26f194 100644
--- a/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.unit.spec.tsx
+++ b/enterprise/frontend/src/embedding-sdk/components/public/InteractiveQuestion/InteractiveQuestion.unit.spec.tsx
@@ -18,6 +18,7 @@ import {
 import { InteractiveQuestionResult } from "embedding-sdk/components/private/InteractiveQuestionResult";
 import { createMockAuthProviderUriConfig } from "embedding-sdk/test/mocks/config";
 import { setupSdkState } from "embedding-sdk/test/server-mocks/sdk-init";
+import type { SdkQuestionTitleProps } from "embedding-sdk/types/question";
 import {
   createMockCard,
   createMockCardQueryMetadata,
@@ -53,23 +54,29 @@ const TEST_DATASET = createMockDataset({
 });
 
 // Provides a button to re-run the query
-function InteractiveQuestionCustomLayout() {
+function InteractiveQuestionCustomLayout({
+  title,
+}: {
+  title?: SdkQuestionTitleProps;
+}) {
   const { resetQuestion } = useInteractiveQuestionContext();
 
   return (
     <div>
       <button onClick={resetQuestion}>Run Query</button>
-      <InteractiveQuestionResult withTitle />
+      <InteractiveQuestionResult title={title} />
     </div>
   );
 }
 
 const setup = ({
   isValidCard = true,
+  title,
   withCustomLayout = false,
   withChartTypeSelector = false,
 }: {
   isValidCard?: boolean;
+  title?: SdkQuestionTitleProps;
   withCustomLayout?: boolean;
   withChartTypeSelector?: boolean;
 } = {}) => {
@@ -77,7 +84,7 @@ const setup = ({
     currentUser: TEST_USER,
   });
 
-  const TEST_CARD = createMockCard();
+  const TEST_CARD = createMockCard({ name: "My Question" });
   if (isValidCard) {
     setupCardEndpoints(TEST_CARD);
     setupCardQueryMetadataEndpoint(
@@ -99,6 +106,7 @@ const setup = ({
   return renderWithProviders(
     <InteractiveQuestion
       questionId={TEST_CARD.id}
+      title={title}
       withChartTypeSelector={withChartTypeSelector}
     >
       {withCustomLayout ? <InteractiveQuestionCustomLayout /> : undefined}
@@ -180,6 +188,35 @@ describe("InteractiveQuestion", () => {
     expect(screen.getByText("Question not found")).toBeInTheDocument();
   });
 
+  it.each([
+    // shows the question title by default
+    [undefined, "My Question"],
+
+    // hides the question title when title={false}
+    [false, null],
+
+    // shows the default question title when title={true}
+    [true, "My Question"],
+
+    // customizes the question title via strings
+    ["Foo Bar", "Foo Bar"],
+
+    // customizes the question title via React elements
+    [<h1 key="foo">Foo Bar</h1>, "Foo Bar"],
+
+    // customizes the question title via React components.
+    [() => <h1>Foo Bar</h1>, "Foo Bar"],
+  ])(
+    "shows the question title according to the title prop",
+    async (titleProp, expectedTitle) => {
+      setup({ title: titleProp });
+      await waitForLoaderToBeRemoved();
+
+      const element = screen.queryByText(expectedTitle ?? "My Question");
+      expect(element?.textContent ?? null).toBe(expectedTitle);
+    },
+  );
+
   it("should show a chart type selector button if withChartTypeSelector is true", async () => {
     setup({ withChartTypeSelector: true });
     await waitForLoaderToBeRemoved();
diff --git a/enterprise/frontend/src/embedding-sdk/types/question.ts b/enterprise/frontend/src/embedding-sdk/types/question.ts
index 984c86528fc..2c8ee7c118a 100644
--- a/enterprise/frontend/src/embedding-sdk/types/question.ts
+++ b/enterprise/frontend/src/embedding-sdk/types/question.ts
@@ -1,3 +1,5 @@
+import type { ReactNode } from "react";
+
 import type { Deferred } from "metabase/lib/promise";
 import type { QueryParams } from "metabase/query_builder/actions";
 import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/types";
@@ -23,3 +25,10 @@ export interface NavigateToNewCardParams {
   objectId: ObjectId;
   cancelDeferred?: Deferred;
 }
+
+export type SdkQuestionTitleProps =
+  | boolean
+  | undefined
+  | ReactNode
+  // TODO: turn this into (question: Question) => ReactNode once we have the public-facing question type (metabase#50487)
+  | (() => ReactNode);
-- 
GitLab