Skip to content
Snippets Groups Projects
Unverified Commit c6378ef4 authored by github-automation-metabase's avatar github-automation-metabase Committed by GitHub
Browse files

feat(sdk): expose FilterPicker querying component (#49768) (#49906)


* add filter picker skeleton

* add filter picker skeleton

* add picker in popover use case

* reuse FilterColumnPicker

* add question filter logic

* remove fixed width from popover

* add onClose handler

* add e2e test skeleton

* contain FilterBar in Box in story

* add a test for adding filters

* update e2e tests

* add filter picker to the docs

* inline props and functions

* update the props

Co-authored-by: default avatarPhoomparin Mano <poom@metabase.com>
parent 615ce94b
Branches
Tags
No related merge requests found
Showing
with 253 additions and 77 deletions
......@@ -141,6 +141,7 @@ These components are available via the `InteractiveQuestion` namespace (e.g., `<
| `BackButton` | The back button, which provides `back` functionality for the InteractiveDashboard |
| `FilterBar` | The row of badges that contains the current filters that are applied to the question |
| `Filter` | The Filter pane containing all possible filters |
| `FilterPicker` | Picker for adding a new filter to the question |
| `FilterButton` | The button used in the default layout to open the Filter pane. You can replace this button with your own implementation. |
| `ResetButton` | The button used to reset the question after the question has been modified with filters/aggregations/etc |
| `Title` | The question's title |
......
import {
setTokenFeatures,
visitFullAppEmbeddingUrl,
} from "e2e/support/helpers";
import { EMBEDDING_SDK_STORY_HOST } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import {
JWT_SHARED_SECRET,
setupJwt,
} from "e2e/support/helpers/e2e-jwt-helpers";
const DEFAULT_INTERACTIVE_QUESTION_STORY_ID =
"embeddingsdk-interactivequestion--default";
export function signInAsAdminAndEnableEmbeddingSdk() {
cy.signInAsAdmin();
setTokenFeatures("all");
setupJwt();
cy.request("PUT", "/api/setting", {
"enable-embedding-sdk": true,
});
}
export const getSdkRoot = () =>
cy.get("#metabase-sdk-root").should("be.visible");
/** Get storybook args in the format of as "key:value;key:value" */
export function getStorybookArgs(props: Record<string, string>): string {
const params = new URLSearchParams(props);
return params.toString().replaceAll("=", ":").replaceAll("&", ";");
}
export function visitInteractiveQuestionStory({
storyId = DEFAULT_INTERACTIVE_QUESTION_STORY_ID,
saveToCollectionId,
}: { storyId?: string; saveToCollectionId?: number } = {}) {
const params: Record<string, string> = {
...(saveToCollectionId && {
saveToCollectionId: saveToCollectionId.toString(),
}),
};
cy.intercept("GET", "/api/card/*").as("getCard");
cy.intercept("GET", "/api/user/current").as("getUser");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
cy.get("@questionId").then(questionId => {
visitFullAppEmbeddingUrl({
url: EMBEDDING_SDK_STORY_HOST,
qs: {
id: storyId,
viewMode: "story",
args: getStorybookArgs(params),
},
onBeforeLoad: (window: any) => {
window.JWT_SHARED_SECRET = JWT_SHARED_SECRET;
window.METABASE_INSTANCE_URL = Cypress.config().baseUrl;
window.QUESTION_ID = questionId;
},
});
});
cy.wait("@getUser").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
});
cy.wait("@getCard").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
});
}
import {
entityPickerModal,
modal,
popover,
visitFullAppEmbeddingUrl,
} from "e2e/support/helpers";
import { EMBEDDING_SDK_STORY_HOST } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { JWT_SHARED_SECRET } from "e2e/support/helpers/e2e-jwt-helpers";
/** Get storybook args in the format of as "key:value;key:value" */
export function getStorybookArgs(props: Record<string, string>): string {
const params = new URLSearchParams(props);
return params.toString().replaceAll("=", ":").replaceAll("&", ";");
}
export function visitInteractiveQuestionStory(
options: { saveToCollectionId?: number } = {},
) {
const params: Record<string, string> = {
...(options.saveToCollectionId && {
saveToCollectionId: options.saveToCollectionId.toString(),
}),
};
cy.get("@questionId").then(questionId => {
visitFullAppEmbeddingUrl({
url: EMBEDDING_SDK_STORY_HOST,
qs: {
id: "embeddingsdk-interactivequestion--default",
viewMode: "story",
args: getStorybookArgs(params),
},
onBeforeLoad: (window: any) => {
window.JWT_SHARED_SECRET = JWT_SHARED_SECRET;
window.METABASE_INSTANCE_URL = Cypress.config().baseUrl;
window.QUESTION_ID = questionId;
},
});
});
}
import { entityPickerModal, modal, popover } from "e2e/support/helpers";
export function saveInteractiveQuestionAsNewQuestion(options: {
questionName: string;
......
......@@ -7,28 +7,23 @@ import {
createQuestion,
popover,
restore,
setTokenFeatures,
tableHeaderClick,
tableInteractive,
} from "e2e/support/helpers";
import { describeSDK } from "e2e/support/helpers/e2e-embedding-sdk-helpers";
import { setupJwt } from "e2e/support/helpers/e2e-jwt-helpers";
import {
saveInteractiveQuestionAsNewQuestion,
getSdkRoot,
signInAsAdminAndEnableEmbeddingSdk,
visitInteractiveQuestionStory,
} from "e2e/test/scenarios/embedding-sdk/helpers/save-interactive-question-e2e-helpers";
} from "e2e/test/scenarios/embedding-sdk/helpers/interactive-question-e2e-helpers";
import { saveInteractiveQuestionAsNewQuestion } from "e2e/test/scenarios/embedding-sdk/helpers/save-interactive-question-e2e-helpers";
const { ORDERS, ORDERS_ID } = SAMPLE_DATABASE;
describeSDK("scenarios > embedding-sdk > interactive-question", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
setTokenFeatures("all");
setupJwt();
cy.request("PUT", "/api/setting", {
"enable-embedding-sdk": true,
});
signInAsAdminAndEnableEmbeddingSdk();
createQuestion(
{
......@@ -44,32 +39,20 @@ describeSDK("scenarios > embedding-sdk > interactive-question", () => {
);
cy.signOut();
});
cy.intercept("GET", "/api/card/*").as("getCard");
cy.intercept("GET", "/api/user/current").as("getUser");
cy.intercept("POST", "/api/card/*/query").as("cardQuery");
it("should show question content", () => {
visitInteractiveQuestionStory();
cy.wait("@getUser").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
});
cy.wait("@getCard").then(({ response }) => {
expect(response?.statusCode).to.equal(200);
getSdkRoot().within(() => {
cy.findByText("Product ID").should("be.visible");
cy.findByText("Max of Quantity").should("be.visible");
});
});
it("should show question content", () => {
cy.get("#metabase-sdk-root")
.should("be.visible")
.within(() => {
cy.findByText("Product ID").should("be.visible");
cy.findByText("Max of Quantity").should("be.visible");
});
});
it("should not fail on aggregated question drill", () => {
visitInteractiveQuestionStory();
cy.wait("@cardQuery").then(({ response }) => {
expect(response?.statusCode).to.equal(202);
});
......@@ -90,6 +73,8 @@ describeSDK("scenarios > embedding-sdk > interactive-question", () => {
});
it("should be able to hide columns from a table", () => {
visitInteractiveQuestionStory();
cy.wait("@cardQuery").then(({ response }) => {
expect(response?.statusCode).to.equal(202);
});
......@@ -106,6 +91,8 @@ describeSDK("scenarios > embedding-sdk > interactive-question", () => {
});
it("can save a question to a default collection", () => {
visitInteractiveQuestionStory();
saveInteractiveQuestionAsNewQuestion({
entityName: "Orders",
questionName: "Sample Orders 1",
......@@ -119,6 +106,8 @@ describeSDK("scenarios > embedding-sdk > interactive-question", () => {
});
it("can save a question to a selected collection", () => {
visitInteractiveQuestionStory();
saveInteractiveQuestionAsNewQuestion({
entityName: "Orders",
questionName: "Sample Orders 2",
......@@ -148,4 +137,21 @@ describeSDK("scenarios > embedding-sdk > interactive-question", () => {
expect(response?.body.collection_id).to.equal(THIRD_COLLECTION_ID);
});
});
it("can add a filter via the FilterPicker component", () => {
visitInteractiveQuestionStory({
storyId:
"embeddingsdk-interactivequestion-filterpicker--picker-in-popover",
});
getSdkRoot().findByText("Filter").click();
popover().within(() => {
cy.findByText("User ID").click();
cy.findByPlaceholderText("Enter an ID").type("12");
cy.findByText("Add filter").click();
});
getSdkRoot().contains("User ID is 12");
});
});
.PickerContainer {
font-family: var(--mb-default-font-family);
font-size: 13px;
/** SDK does not have CSS resets for buttons, so we need to reset them here */
button[disabled],
button[type="button"] {
border: none;
background: none;
}
}
import { useDisclosure } from "@mantine/hooks";
import { InteractiveQuestion } from "embedding-sdk";
import { CommonSdkStoryWrapper } from "embedding-sdk/test/CommonSdkStoryWrapper";
import { Box, Button, Flex, Popover } from "metabase/ui";
import { FilterPicker } from "./FilterPicker";
const QUESTION_ID = (window as any).QUESTION_ID || 12;
export default {
title: "EmbeddingSDK/InteractiveQuestion/FilterPicker",
component: FilterPicker,
parameters: {
layout: "fullscreen",
},
decorators: [CommonSdkStoryWrapper],
};
export const PickerInPopover = {
render() {
const [isOpen, { close, toggle }] = useDisclosure();
return (
<Box p="lg">
<InteractiveQuestion questionId={QUESTION_ID}>
<Box>
<Flex justify="space-between" w="100%">
<Box>
<InteractiveQuestion.FilterBar />
</Box>
<Popover position="bottom-end" opened={isOpen} onClose={close}>
<Popover.Target>
<Button onClick={toggle}>Filter</Button>
</Popover.Target>
<Popover.Dropdown>
<InteractiveQuestion.FilterPicker onClose={close} withIcon />
</Popover.Dropdown>
</Popover>
</Flex>
<InteractiveQuestion.QuestionVisualization />
</Box>
</InteractiveQuestion>
</Box>
);
},
};
import cx from "classnames";
import { FilterPicker as InnerFilterPicker } from "metabase/querying/filters/components/FilterPicker";
import { Box } from "metabase/ui";
import * as Lib from "metabase-lib";
import { useInteractiveQuestionContext } from "../context";
import S from "./FilterPicker.module.css";
interface Props {
className?: string;
withIcon?: boolean;
onClose?: () => void;
}
export const FilterPicker = ({
className,
withIcon = false,
onClose,
}: Props) => {
const { question, updateQuestion } = useInteractiveQuestionContext();
const query = question?.query();
if (!query) {
return null;
}
return (
<Box className={cx(S.PickerContainer, className)}>
<InnerFilterPicker
query={query}
stageIndex={-1}
onClose={onClose}
onSelect={filter => {
const nextQuery = Lib.filter(query, -1, filter);
if (question) {
updateQuestion(question.setQuery(nextQuery), { run: true });
onClose?.();
}
}}
withCustomExpression={false}
withColumnGroupIcon={false}
withColumnItemIcon={withIcon}
/>
</Box>
);
};
......@@ -3,6 +3,7 @@ export * from "./ChartTypeSelector";
export * from "./Filter";
export * from "./FilterBar";
export * from "./FilterButton";
export * from "./FilterPicker";
export * from "./Editor";
export * from "./EditorButton";
export * from "./ResetButton";
......
......@@ -9,6 +9,7 @@ import {
Filter,
FilterBar,
FilterButton,
FilterPicker,
QuestionResetButton,
QuestionSettings,
QuestionVisualization,
......@@ -90,6 +91,7 @@ const InteractiveQuestion = withPublicComponentWrapper(
BackButton: typeof BackButton;
FilterBar: typeof FilterBar;
Filter: typeof Filter;
FilterPicker: typeof FilterPicker;
FilterButton: typeof FilterButton;
ResetButton: typeof QuestionResetButton;
Title: typeof Title;
......@@ -113,6 +115,7 @@ InteractiveQuestion.BackButton = BackButton;
InteractiveQuestion.FilterBar = FilterBar;
InteractiveQuestion.Filter = Filter;
InteractiveQuestion.FilterButton = FilterButton;
InteractiveQuestion.FilterPicker = FilterPicker;
InteractiveQuestion.ResetButton = QuestionResetButton;
InteractiveQuestion.Title = Title;
InteractiveQuestion.Summarize = Summarize;
......
......@@ -21,6 +21,10 @@ export interface FilterColumnPickerProps {
onColumnSelect: (column: Lib.ColumnMetadata) => void;
onSegmentSelect: (segment: Lib.SegmentMetadata) => void;
onExpressionSelect: () => void;
withCustomExpression?: boolean;
withColumnGroupIcon?: boolean;
withColumnItemIcon?: boolean;
}
type Section = {
......@@ -56,6 +60,9 @@ export function FilterColumnPicker({
onColumnSelect,
onSegmentSelect,
onExpressionSelect,
withCustomExpression = true,
withColumnGroupIcon = true,
withColumnItemIcon = true,
}: FilterColumnPickerProps) {
const sections = useMemo(() => {
const columns = Lib.filterableColumns(query, stageIndex);
......@@ -82,13 +89,16 @@ export function FilterColumnPicker({
return {
name: groupInfo.displayName,
icon: getColumnGroupIcon(groupInfo),
icon: withColumnGroupIcon ? getColumnGroupIcon(groupInfo) : null,
items: [...segmentItems, ...columnItems],
};
});
return [...sections, CUSTOM_EXPRESSION_SECTION];
}, [query, stageIndex]);
return [
...sections,
...(withCustomExpression ? [CUSTOM_EXPRESSION_SECTION] : []),
];
}, [query, stageIndex, withColumnGroupIcon, withCustomExpression]);
const handleSectionChange = (section: Section) => {
if (section.key === "custom-expression") {
......@@ -114,7 +124,9 @@ export function FilterColumnPicker({
renderItemWrapper={renderItemWrapper}
renderItemName={renderItemName}
renderItemDescription={omitItemDescription}
renderItemIcon={renderItemIcon}
renderItemIcon={(item: ColumnListItem | SegmentListItem) =>
withColumnItemIcon ? renderItemIcon(item) : null
}
// disable scrollbars inside the list
style={{ overflow: "visible" }}
maxHeight={Infinity}
......
......@@ -5,11 +5,14 @@ import { ExpressionWidget } from "metabase/query_builder/components/expressions/
import { ExpressionWidgetHeader } from "metabase/query_builder/components/expressions/ExpressionWidgetHeader";
import * as Lib from "metabase-lib";
import { FilterColumnPicker } from "./FilterColumnPicker";
import {
FilterColumnPicker,
type FilterColumnPickerProps,
} from "./FilterColumnPicker";
import { FilterPickerBody } from "./FilterPickerBody";
import type { ColumnListItem, SegmentListItem } from "./types";
export interface FilterPickerProps {
export type FilterPickerProps = {
query: Lib.Query;
stageIndex: number;
filter?: Lib.FilterClause;
......@@ -17,7 +20,10 @@ export interface FilterPickerProps {
onSelect: (filter: Lib.Filterable) => void;
onClose?: () => void;
}
} & Pick<
FilterColumnPickerProps,
"withColumnItemIcon" | "withColumnGroupIcon" | "withCustomExpression"
>;
export function FilterPicker({
query,
......@@ -26,6 +32,9 @@ export function FilterPicker({
filterIndex,
onSelect,
onClose,
withColumnItemIcon,
withColumnGroupIcon,
withCustomExpression,
}: FilterPickerProps) {
const [filter, setFilter] = useState(initialFilter);
......@@ -96,6 +105,9 @@ export function FilterPicker({
onColumnSelect={handleColumnSelect}
onSegmentSelect={handleChange}
onExpressionSelect={openExpressionEditor}
withColumnGroupIcon={withColumnGroupIcon}
withColumnItemIcon={withColumnItemIcon}
withCustomExpression={withCustomExpression}
/>
);
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment