From 277fc1df72ccda75b6e9dfd717ea78190d3dd277 Mon Sep 17 00:00:00 2001 From: github-automation-metabase <166700802+github-automation-metabase@users.noreply.github.com> Date: Mon, 18 Nov 2024 12:35:30 -0500 Subject: [PATCH] Add generic components for summarize and breakout (#50039) (#50148) Co-authored-by: Oisin Coveney <oisin@metabase.com> --- .../AddBadgeListItem/AddBadgeListItem.tsx | 32 +++++++++ .../AddBadgeListItem.unit.spec.tsx | 30 ++++++++ .../util/BadgeList/AddBadgeListItem/index.ts | 1 + .../util/BadgeList/BadgeList.stories.tsx | 69 ++++++++++++++++++ .../components/util/BadgeList/BadgeList.tsx | 39 ++++++++++ .../util/BadgeList/BadgeList.unit.spec.tsx | 72 +++++++++++++++++++ .../BadgeList/BadgeListItem/BadgeListItem.tsx | 47 ++++++++++++ .../BadgeListItem/BadgeListItem.unit.spec.tsx | 44 ++++++++++++ .../util/BadgeList/BadgeListItem/index.ts | 1 + .../components/util/BadgeList/index.ts | 1 + .../src/metabase/css/core/colors.module.css | 3 +- 11 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.unit.spec.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/index.ts create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.stories.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.unit.spec.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.unit.spec.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/index.ts create mode 100644 enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/index.ts diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.tsx new file mode 100644 index 00000000000..1643adf21a6 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.tsx @@ -0,0 +1,32 @@ +import { Badge } from "@mantine/core"; + +import CS from "metabase/css/core/index.css"; +import { ActionIcon, Icon } from "metabase/ui"; + +type AddBadgeListItemProps = { + name: string; + onClick: () => void; +}; + +export const AddBadgeListItem = ({ name, onClick }: AddBadgeListItemProps) => ( + <Badge + classNames={{ + inner: CS.cursorPointer, + }} + bg="var(--mb-color-bg-light)" + tt="capitalize" + size="lg" + variant="transparent" + c="var(--mb-color-text-brand)" + pr="sm" + pl="xs" + leftSection={ + <ActionIcon radius="xl" size="sm" className={CS.bgMediumHover}> + <Icon name="add" c="var(--mb-color-text-brand)" size={10} /> + </ActionIcon> + } + onClick={onClick} + > + {name} + </Badge> +); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.unit.spec.tsx new file mode 100644 index 00000000000..04ced6356c6 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/AddBadgeListItem.unit.spec.tsx @@ -0,0 +1,30 @@ +import userEvent from "@testing-library/user-event"; + +import { render, screen } from "__support__/ui"; + +import { AddBadgeListItem } from "./AddBadgeListItem"; + +const setup = () => { + const name = "test badge"; + const handleClick = jest.fn(); + render(<AddBadgeListItem name={name} onClick={handleClick} />); + return { handleClick }; +}; + +describe("AddBadgeListItem", () => { + it("renders badge with correct name", () => { + setup(); + expect(screen.getByText("test badge")).toBeInTheDocument(); + }); + + it("calls onClick when clicked", async () => { + const { handleClick } = setup(); + await userEvent.click(screen.getByText("test badge")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("renders add icon", () => { + setup(); + expect(screen.getByLabelText("add icon")).toBeInTheDocument(); + }); +}); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/index.ts b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/index.ts new file mode 100644 index 00000000000..5075a720b39 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/AddBadgeListItem/index.ts @@ -0,0 +1 @@ +export * from "./AddBadgeListItem"; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.stories.tsx new file mode 100644 index 00000000000..effba8ec542 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.stories.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; + +import { Box, Stack } from "metabase/ui"; + +import { BadgeList } from "./BadgeList"; + +export default { + title: "BadgeList", + component: BadgeList, + parameters: { + layout: "fullscreen", + }, +}; + +export const DefaultLayoutBadgeList = { + render() { + const [items, setItems] = useState( + Array.from(Array(5).keys()).map(i => ({ + name: `item ${i}`, + item: i, + })), + ); + + const [selectedItem, setSelectedItem] = useState<{ + item?: number; + index?: number; + }>({}); + + const onSelectItem = (item?: number, index?: number) => { + if (item === selectedItem?.item) { + setSelectedItem({}); + } else { + setSelectedItem({ item, index }); + } + }; + + const onAddItem = () => + setItems(nextItems => [ + ...nextItems, + { name: `item ${nextItems.length}`, item: nextItems.length }, + ]); + + const onRemoveItem = (_item?: number, index?: number) => { + if (typeof index === "number") { + setItems(nextItems => [ + ...nextItems.slice(0, index), + ...nextItems.slice(index + 1), + ]); + } + }; + + return ( + <Stack> + <BadgeList + items={items} + onSelectItem={onSelectItem} + onAddItem={onAddItem} + onRemoveItem={onRemoveItem} + addButtonLabel="Add another item" + /> + <Box p="md"> + {selectedItem?.item + ? `The selected element is ${selectedItem.item} at index ${selectedItem.index}` + : "No element has been selected"} + </Box> + </Stack> + ); + }, +}; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.tsx new file mode 100644 index 00000000000..425abeb9b3b --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.tsx @@ -0,0 +1,39 @@ +import { Group, Paper } from "metabase/ui"; + +import { AddBadgeListItem } from "./AddBadgeListItem"; +import { BadgeListItem } from "./BadgeListItem"; + +export type BadgeListProps<T> = { + items: { + name: string; + item: T; + }[]; + onSelectItem?: (item?: T, index?: number) => void; + onAddItem?: (item?: T) => void; + onRemoveItem?: (item?: T, index?: number) => void; + addButtonLabel?: string; +}; + +export const BadgeList = <T,>({ + items, + onSelectItem, + onAddItem, + onRemoveItem, + addButtonLabel, +}: BadgeListProps<T>) => ( + <Paper p="md" w="30rem"> + <Group spacing="sm"> + {items.map(({ name, item }, index) => ( + <BadgeListItem + key={`${name}/${index}`} + onSelectItem={() => onSelectItem?.(item, index)} + onRemoveItem={() => onRemoveItem?.(item, index)} + name={name} + /> + ))} + {addButtonLabel && ( + <AddBadgeListItem name={addButtonLabel} onClick={() => onAddItem?.()} /> + )} + </Group> + </Paper> +); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.unit.spec.tsx new file mode 100644 index 00000000000..dbc2cfbaf64 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeList.unit.spec.tsx @@ -0,0 +1,72 @@ +import userEvent from "@testing-library/user-event"; + +import { render, screen } from "__support__/ui"; + +import { BadgeList, type BadgeListProps } from "./BadgeList"; + +type SetupOpts = Partial<BadgeListProps<{ id: number }>>; + +const setup = (opts: SetupOpts = {}) => { + const items = [ + { name: "item1", item: { id: 1 } }, + { name: "item2", item: { id: 2 } }, + ]; + const onSelectItem = jest.fn(); + const onAddItem = jest.fn(); + const onRemoveItem = jest.fn(); + const addButtonLabel = + "addButtonLabel" in opts ? opts.addButtonLabel : "Add new"; + + render( + <BadgeList + items={items} + onSelectItem={onSelectItem} + onRemoveItem={onRemoveItem} + onAddItem={onAddItem} + addButtonLabel={addButtonLabel} + />, + ); + + return { + onSelectItem, + onAddItem, + onRemoveItem, + }; +}; + +describe("BadgeList", () => { + it("renders all items", () => { + setup(); + expect(screen.getByText("item1")).toBeInTheDocument(); + expect(screen.getByText("item2")).toBeInTheDocument(); + }); + + it("renders add button when label is provided", () => { + setup(); + expect(screen.getByText("Add new")).toBeInTheDocument(); + }); + + it("doesn't render add button when label is not provided", () => { + setup({ addButtonLabel: undefined }); + expect(screen.queryByText("Add new")).not.toBeInTheDocument(); + }); + + it("calls onSelectItem with correct item when badge is clicked", async () => { + const { onSelectItem } = setup(); + await userEvent.click(screen.getByText("item1")); + expect(onSelectItem).toHaveBeenCalledWith({ id: 1 }, 0); + }); + + it("calls onRemoveItem with correct item when remove button is clicked", async () => { + const { onRemoveItem } = setup(); + const removeButtons = screen.getAllByLabelText("close icon"); + await userEvent.click(removeButtons[0]); + expect(onRemoveItem).toHaveBeenCalledWith({ id: 1 }, 0); + }); + + it("calls onAddItem when add button is clicked", async () => { + const { onAddItem } = setup(); + await userEvent.click(screen.getByText("Add new")); + expect(onAddItem).toHaveBeenCalled(); + }); +}); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.tsx new file mode 100644 index 00000000000..b34efd65694 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.tsx @@ -0,0 +1,47 @@ +import { Badge } from "@mantine/core"; + +import CS from "metabase/css/core/index.css"; +import { ActionIcon, Icon } from "metabase/ui"; + +type BadgeListItemProps = { + onSelectItem?: () => void; + onRemoveItem?: () => void; + name: string; +}; + +export const BadgeListItem = ({ + name, + onRemoveItem, + onSelectItem, +}: BadgeListItemProps) => ( + <Badge + size="lg" + tt="capitalize" + variant="light" + bg="var(--mb-color-brand-light)" + c="var(--mb-color-text-brand)" + classNames={{ + root: CS.bgLightHover, + inner: CS.cursorPointer, + }} + onClick={onSelectItem} + pr={0} + pl="sm" + rightSection={ + <ActionIcon + radius="xl" + size="sm" + ml={0} + onClick={e => { + e.stopPropagation(); + onRemoveItem?.(); + }} + className={CS.bgMediumHover} + > + <Icon name="close" c="var(--mb-color-text-brand)" size={10} /> + </ActionIcon> + } + > + {name} + </Badge> +); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.unit.spec.tsx b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.unit.spec.tsx new file mode 100644 index 00000000000..bb24ef937cb --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/BadgeListItem.unit.spec.tsx @@ -0,0 +1,44 @@ +import userEvent from "@testing-library/user-event"; + +import { render, screen } from "__support__/ui"; + +import { BadgeListItem } from "./BadgeListItem"; + +const setup = () => { + const name = "test badge"; + const onSelectItem = jest.fn(); + const onRemoveItem = jest.fn(); + render( + <BadgeListItem + name={name} + onSelectItem={onSelectItem} + onRemoveItem={onRemoveItem} + />, + ); + return { onSelectItem, onRemoveItem }; +}; + +describe("BadgeListItem", () => { + it("renders badge with correct name", () => { + setup(); + expect(screen.getByText("test badge")).toBeInTheDocument(); + }); + + it("calls onSelectItem when badge is clicked", async () => { + const { onSelectItem } = setup(); + await userEvent.click(screen.getByText("test badge")); + expect(onSelectItem).toHaveBeenCalledTimes(1); + }); + + it("prevents badge click event when clicking remove button", async () => { + const { onSelectItem, onRemoveItem } = setup(); + await userEvent.click(screen.getByLabelText("close icon")); + expect(onRemoveItem).toHaveBeenCalledTimes(1); + expect(onSelectItem).not.toHaveBeenCalled(); + }); + + it("renders close icon", () => { + setup(); + expect(screen.getByLabelText("close icon")).toBeInTheDocument(); + }); +}); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/index.ts b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/index.ts new file mode 100644 index 00000000000..58b16911d43 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/BadgeListItem/index.ts @@ -0,0 +1 @@ +export * from "./BadgeListItem"; diff --git a/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/index.ts b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/index.ts new file mode 100644 index 00000000000..95f8e34c01d --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/private/InteractiveQuestion/components/util/BadgeList/index.ts @@ -0,0 +1 @@ +export * from "./BadgeList"; diff --git a/frontend/src/metabase/css/core/colors.module.css b/frontend/src/metabase/css/core/colors.module.css index 9b7b6872417..a8f6db09eda 100644 --- a/frontend/src/metabase/css/core/colors.module.css +++ b/frontend/src/metabase/css/core/colors.module.css @@ -354,7 +354,8 @@ background-color: var(--mb-color-bg-light); } -.bgMedium { +.bgMedium, +.bgMediumHover:hover { background-color: var(--mb-color-bg-medium); } -- GitLab