diff --git a/docs/embedding/sdk/questions.md b/docs/embedding/sdk/questions.md index 43b1f722ea16fc6c6031f3e198ca0a02f6ea04cc..1e5b5f0e5d14a3e2c6e844aa9794f4e7c67892ef 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 9840058a490dc189d069cd3dd5e602981b668ff9..5b626e9692c9de612aaa3f11c2b5b58528beb523 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 d191b8242b7015286253a9507dda5db4c3f1df1d..5d73142d72eb5d597b57446adc7220962ee56d88 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 7b82ac13865b37b543c805da9f16190ae81afdaf..bb00415bdafb9c3600bf44baf511655392693068 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 aeebc991e4e1ecdda3d673e3edd3ae7eb9ff5736..0b555d8f32fc0f0e0eae98901a2f1e8a85d15dd0 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 b6e9ca90cc3d6f3ef5e5a0b00f8d014732e54b2f..4bf5229eda96f359a4d3d765b660500db886279b 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 6eb3a7c61bce57d8b5a3e7dfebe3fcd86ccdb703..a916d26f1948ac50477dc2d88b9f285c921955e7 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 984c86528fcb91bf3ed76f231e5108c56692d2c3..2c8ee7c118a9b8660b9e4c6a3d3fd6cf8e117c6c 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);