Skip to content
Snippets Groups Projects
Unverified Commit 5fb95767 authored by Phoomparin Mano's avatar Phoomparin Mano Committed by GitHub
Browse files

feat(sdk): revamp CreateQuestion and create question behaviour (#50088)

* revamp the create question component

* docs updates

* add stories and update styles

* prevent switching to visualization when it is not ready

* ensure CreateQuestion works in flex parents

* add basic e2e tests for CreateQuestion

* ensure that switching between editor and visualization works

* use the save button for the disabled state

* update the question title when the question is saved

* implement updating questions in place

* rename replaceQuestion

* make CreateQuestion props all optional

* add background color to modal header

* hide the save button if question is dirty

* hide save button instead of disabling per design feedback

* update e2e tests

* revert e2e helpers

* add title update assertions

* whitespace changes
parent 1ac47c74
No related branches found
No related tags found
No related merge requests found
Showing
with 253 additions and 30 deletions
......@@ -241,6 +241,10 @@ export default function App() {
With the `CreateQuestion` component, you can embed the query builder without a pre-defined question.
This component is built on top of the `InteractiveQuestion` component with [namespaced components](#interactive-question-components). It [shares the same props as InteractiveQuestion](#question-props), except it lacks the `questionId` prop and the ability to pass custom children.
To customize the question editor's layout, use the `InteractiveQuestion` component [directly with a custom `children` prop](#customizing-interactive-questions).
```tsx
import React from "react";
import {MetabaseProvider, CreateQuestion} from "@metabase/embedding-sdk-react";
......@@ -250,7 +254,7 @@ const config = {...}
export default function App() {
return (
<MetabaseProvider config={config}>
<CreateQuestion/>
<CreateQuestion />
</MetabaseProvider>
);
}
......
import {
entityPickerModal,
modal,
restore,
visitFullAppEmbeddingUrl,
} from "e2e/support/helpers";
import {
EMBEDDING_SDK_STORY_HOST,
describeSDK,
} from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { JWT_SHARED_SECRET } from "e2e/support/helpers/e2e-jwt-helpers";
import {
getSdkRoot,
signInAsAdminAndEnableEmbeddingSdk,
} from "e2e/test/scenarios/embedding-sdk/helpers/interactive-question-e2e-helpers";
describeSDK("scenarios > embedding-sdk > create-question", () => {
beforeEach(() => {
restore();
signInAsAdminAndEnableEmbeddingSdk();
cy.signOut();
});
it("can create questions via the CreateQuestion component", () => {
cy.intercept("POST", "/api/card").as("createCard");
visitFullAppEmbeddingUrl({
url: EMBEDDING_SDK_STORY_HOST,
qs: { id: "embeddingsdk-createquestion--default", viewMode: "story" },
onBeforeLoad: (window: any) => {
window.JWT_SHARED_SECRET = JWT_SHARED_SECRET;
window.METABASE_INSTANCE_URL = Cypress.config().baseUrl;
},
});
// Wait until the entity picker modal is visible
getSdkRoot().contains("Pick your starting data");
entityPickerModal().within(() => {
cy.findByText("Tables").click();
cy.findByText("Orders").click();
});
getSdkRoot().within(() => {
// The question title's header should be "New question" by default.
cy.contains("New question");
cy.findByRole("button", { name: "Visualize" }).click();
// Should be able to go back to the editor view
cy.findByRole("button", { name: "Show editor" }).click();
// Should be able to visualize the question again
cy.findByRole("button", { name: "Visualize" }).click();
// Should be able to save to a new question right away
cy.findByRole("button", { name: "Save" }).click();
});
modal().within(() => {
cy.findByPlaceholderText("What is the name of your question?")
.clear()
.type("My Orders");
cy.findByRole("button", { name: "Save" }).click();
});
cy.wait("@createCard").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
expect(response?.body.name).to.equal("My Orders");
});
// The question title's header should be updated.
getSdkRoot().contains("My Orders");
});
});
......@@ -15,9 +15,9 @@ export const SaveButton = ({
? question && question.isQueryDirtyComparedTo(originalQuestion)
: true;
return (
<Button disabled={!isQuestionChanged || !canSave} onClick={onClick}>
Save
</Button>
);
if (!isQuestionChanged || !canSave) {
return null;
}
return <Button onClick={onClick}>Save</Button>;
};
......@@ -78,9 +78,12 @@ export const InteractiveQuestionProvider = ({
const saveContext = { isNewQuestion: true };
await onBeforeSave?.(question, saveContext);
await handleCreateQuestion(question);
onSave?.(question, saveContext);
await loadQuestion();
const createdQuestion = await handleCreateQuestion(question);
onSave?.(createdQuestion, saveContext);
// Set the latest saved question object to update the question title.
replaceQuestion(createdQuestion);
}
};
......@@ -94,6 +97,7 @@ export const InteractiveQuestionProvider = ({
isQueryRunning,
runQuestion,
replaceQuestion,
loadQuestion,
updateQuestion,
navigateToNewCard,
......@@ -120,6 +124,7 @@ export const InteractiveQuestionProvider = ({
onReset: loadQuestion,
onNavigateBack,
runQuestion,
replaceQuestion,
updateQuestion,
navigateToNewCard,
plugins: combinedPlugins,
......
......@@ -135,6 +135,7 @@ const QuestionEditorInner = () => {
);
};
/** @deprecated this is only used in the deprecated `ModifyQuestion` component - to be removed in a future release */
export const QuestionEditor = ({
questionId,
isSaveEnabled = true,
......
import type { StoryFn } from "@storybook/react";
import type { ComponentProps } from "react";
import { CommonSdkStoryWrapper } from "embedding-sdk/test/CommonSdkStoryWrapper";
import { Flex } from "metabase/ui";
import { CreateQuestion } from "./CreateQuestion";
type CreateQuestionComponentProps = ComponentProps<typeof CreateQuestion>;
export default {
title: "EmbeddingSDK/CreateQuestion",
component: CreateQuestion,
parameters: {
layout: "fullscreen",
},
decorators: [CommonSdkStoryWrapper],
};
const Template: StoryFn<CreateQuestionComponentProps> = args => {
return (
<Flex p="xl">
<CreateQuestion {...args} />
</Flex>
);
};
export const Default = {
render: Template,
};
import { QuestionEditor } from "embedding-sdk/components/private/QuestionEditor";
import { useState } from "react";
import type { InteractiveQuestionProps } from "../InteractiveQuestion";
import { FlexibleSizeComponent } from "embedding-sdk";
import { useInteractiveQuestionContext } from "embedding-sdk/components/private/InteractiveQuestion/context";
import { SaveQuestionModal } from "metabase/containers/SaveQuestionModal";
import { Button, Flex } from "metabase/ui";
type CreateQuestionProps = Omit<InteractiveQuestionProps, "questionId">;
import {
InteractiveQuestion,
type InteractiveQuestionProps,
} from "../InteractiveQuestion";
type CreateQuestionProps = Partial<
Omit<InteractiveQuestionProps, "questionId" | "children">
>;
export const CreateQuestion = ({
plugins,
onSave,
onBeforeSave,
entityTypeFilter,
isSaveEnabled,
saveToCollectionId,
}: CreateQuestionProps = {}) => (
<QuestionEditor
plugins={plugins}
onBeforeSave={onBeforeSave}
onSave={onSave}
entityTypeFilter={entityTypeFilter}
isSaveEnabled={isSaveEnabled}
saveToCollectionId={saveToCollectionId}
/>
);
isSaveEnabled = true,
...props
}: CreateQuestionProps = {}) => {
const [isSaveModalOpen, setSaveModalOpen] = useState(false);
return (
<InteractiveQuestion
{...props}
isSaveEnabled={isSaveEnabled}
onSave={(question, context) => {
if (question) {
setSaveModalOpen(false);
onSave?.(question, context);
}
}}
>
<CreateQuestionDefaultView
isSaveModalOpen={isSaveModalOpen}
setSaveModalOpen={setSaveModalOpen}
/>
</InteractiveQuestion>
);
};
export const CreateQuestionDefaultView = ({
isSaveModalOpen,
setSaveModalOpen,
}: {
isSaveModalOpen: boolean;
setSaveModalOpen: (isOpen: boolean) => void;
}) => {
const [isVisualizationView, setIsVisualizationView] = useState(false);
const {
isSaveEnabled,
question,
originalQuestion,
onSave,
onCreate,
queryResults,
saveToCollectionId,
} = useInteractiveQuestionContext();
// We show "question not found" when the query results is not available in QueryVisualization.
// Don't allow switching to visualization view when it is not yet ready.
const isVisualizationReady = question && queryResults;
return (
<FlexibleSizeComponent>
<Flex w="100%" justify="space-between" pb="lg">
<Flex>
<InteractiveQuestion.Title />
</Flex>
<Flex gap="sm">
{isVisualizationReady && (
<Button
onClick={() => setIsVisualizationView(!isVisualizationView)}
>
Show {isVisualizationView ? "editor" : "visualization"}
</Button>
)}
<InteractiveQuestion.SaveButton
onClick={() => setSaveModalOpen(true)}
/>
</Flex>
</Flex>
{isVisualizationView && (
<Flex h="500px">
<InteractiveQuestion.QuestionVisualization />
</Flex>
)}
{!isVisualizationView && (
<InteractiveQuestion.Editor
onApply={() => setIsVisualizationView(true)}
/>
)}
{/* Refer to the SaveQuestionProvider for context on why we have to do it like this */}
{isSaveEnabled && isSaveModalOpen && question && (
<SaveQuestionModal
question={question}
originalQuestion={originalQuestion ?? null}
opened
closeOnSuccess
onClose={() => setSaveModalOpen(false)}
onCreate={onCreate}
onSave={onSave}
saveToCollectionId={saveToCollectionId}
/>
)}
</FlexibleSizeComponent>
);
};
......@@ -38,6 +38,12 @@ export interface LoadQuestionHookResult {
options?: { run?: boolean },
): Promise<void>;
/**
* Replaces both the question and originalQuestion object directly.
* Unlike updateQuestion, this does not turn the question into an ad-hoc question.
*/
replaceQuestion(question: Question): void;
navigateToNewCard(params: NavigateToNewCardParams): Promise<void>;
}
......@@ -51,7 +57,7 @@ export function useLoadQuestion({
// Keep track of the latest question and query results.
// They can be updated from the below actions.
const [questionState, setQuestionState] = useReducer(questionReducer, {});
const { question, queryResults } = questionState;
const { question, originalQuestion, queryResults } = questionState;
const deferredRef = useRef<Deferred>();
......@@ -83,8 +89,6 @@ export function useLoadQuestion({
return state;
}, [dispatch, options, deserializedCard, cardId]);
const { originalQuestion } = loadQuestionState.value ?? {};
const [runQuestionState, runQuestion] = useAsyncFn(async () => {
if (!question) {
return;
......@@ -149,6 +153,9 @@ export function useLoadQuestion({
updateQuestionState.loading ||
navigateToNewCardState.loading;
const replaceQuestion = (question: Question) =>
setQuestionState({ question, originalQuestion: question });
return {
question,
originalQuestion,
......@@ -159,6 +166,7 @@ export function useLoadQuestion({
isQueryRunning,
runQuestion,
replaceQuestion,
loadQuestion,
updateQuestion,
navigateToNewCard,
......
......@@ -6,6 +6,7 @@ import type { Card, CardId } from "metabase-types/api";
export interface SdkQuestionState {
question?: Question;
originalQuestion?: Question;
queryResults?: any[];
}
......
......@@ -30,6 +30,8 @@ export const useCreateQuestion = ({
scheduleCallback?.(async () => {
await dispatch(updateUrl(createdQuestion, { dirty: false }));
});
return createdQuestion;
},
[dispatch, scheduleCallback],
);
......
......@@ -24,6 +24,9 @@ export const getModalOverrides = (): MantineThemeOverride["components"] => ({
content: {
backgroundColor: "var(--mb-color-background)",
},
header: {
backgroundColor: "var(--mb-color-background)",
},
}),
},
ModalRoot: {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment