From a67ccbcaf6706593ae4b9a99af308449e1f13741 Mon Sep 17 00:00:00 2001 From: Yossi Saadi Date: Wed, 30 Aug 2023 11:53:55 +0300 Subject: [PATCH] feat(EmptyState): new component --- .../EmptyState/EmptyState.module.scss | 6 ++ src/components/EmptyState/EmptyState.tsx | 74 +++++++++++++++ .../__stories__/EmptyState.stories.mdx | 54 +++++++++++ .../__stories__/assets/empty_state_img.svg | 1 + .../emptyState-snapshot-tests.jest.js.snap | 48 ++++++++++ .../emptyState-snapshot-tests.jest.js | 16 ++++ .../__tests__/emptyState-tests.jest.tsx | 95 +++++++++++++++++++ src/components/index.js | 1 + src/tests/constants.ts | 1 + webpack/published-ts-components.js | 1 + 10 files changed, 297 insertions(+) create mode 100644 src/components/EmptyState/EmptyState.module.scss create mode 100644 src/components/EmptyState/EmptyState.tsx create mode 100644 src/components/EmptyState/__stories__/EmptyState.stories.mdx create mode 100644 src/components/EmptyState/__stories__/assets/empty_state_img.svg create mode 100644 src/components/EmptyState/__tests__/__snapshots__/emptyState-snapshot-tests.jest.js.snap create mode 100644 src/components/EmptyState/__tests__/emptyState-snapshot-tests.jest.js create mode 100644 src/components/EmptyState/__tests__/emptyState-tests.jest.tsx diff --git a/src/components/EmptyState/EmptyState.module.scss b/src/components/EmptyState/EmptyState.module.scss new file mode 100644 index 0000000000..c56dc9ed34 --- /dev/null +++ b/src/components/EmptyState/EmptyState.module.scss @@ -0,0 +1,6 @@ +.emptyState { + .image { + pointer-events: none; + user-select: none; + } +} diff --git a/src/components/EmptyState/EmptyState.tsx b/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000000..7a15d071db --- /dev/null +++ b/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,74 @@ +import React, { forwardRef, useRef } from "react"; +import cx from "classnames"; +import { useMergeRefs } from "../../hooks"; +import VibeComponentProps from "../../types/VibeComponentProps"; +import VibeComponent from "../../types/VibeComponent"; +import { getTestId } from "../../tests/test-ids-utils"; +import { ComponentDefaultTestId } from "../../tests/constants"; +import styles from "./EmptyState.module.scss"; +import Flex from "../Flex/Flex"; +import Heading from "../Heading/Heading"; +import Text from "../Text/Text"; +import Button from "../Button/Button"; + +export interface EmptyStateProps extends VibeComponentProps { + imgSrc: string; + title: string; + body: string; + onPrimaryActionClick?: () => void; + primaryActionLabel?: string; + onSecondaryActionClick?: () => void; + secondaryActionLabel?: string; + imgClassName?: string; +} + +const EmptyState: VibeComponent = forwardRef( + ( + { + imgSrc, + title, + body, + onPrimaryActionClick, + primaryActionLabel, + onSecondaryActionClick, + secondaryActionLabel, + className, + imgClassName, + id, + "data-testid": dataTestId + }, + ref + ) => { + const componentRef = useRef(null); + const mergedRef = useMergeRefs({ refs: [ref, componentRef] }); + + return ( + + {title} + + {title} + {body} + + + {secondaryActionLabel && ( + + )} + {primaryActionLabel && } + + + ); + } +); + +export default EmptyState; diff --git a/src/components/EmptyState/__stories__/EmptyState.stories.mdx b/src/components/EmptyState/__stories__/EmptyState.stories.mdx new file mode 100644 index 0000000000..0001b08098 --- /dev/null +++ b/src/components/EmptyState/__stories__/EmptyState.stories.mdx @@ -0,0 +1,54 @@ +import EmptyState from "../EmptyState"; +import { ArgsTable, Story, Canvas, Meta } from "@storybook/addon-docs"; +import { createStoryMetaSettingsDecorator } from "../../../storybook"; +import { createComponentTemplate } from "vibe-storybook-components"; +import emptyStateImage from "./assets/empty_state_img.svg"; + +export const metaSettings = createStoryMetaSettingsDecorator({ + component: EmptyState, + actionPropsArray: ["onPrimaryActionClick", "onSecondaryActionClick"] +}); + + + + + +export const emptyStateTemplate = createComponentTemplate(EmptyState); +export const emptyStateTemplateDefaults = { + imgSrc: emptyStateImage, + title: "This is a title", + body: "This is a body, more detailed description", + primaryActionLabel: "Do something", + secondaryActionLabel: "Learn more" +}; + + + +# EmptyState + +- [Overview](#overview) +- [Props](#props) +- [Feedback](#feedback) + +## Overview + +Empty states are used when a list, table, or chart has no items or data to show. + +By providing constructive guidance about next steps, they enlighten users about what they would see if they had data. + +An empty state ensures a smooth experience, even when things do not work as expected. + + + + {emptyStateTemplate.bind({})} + + + +## Props + + diff --git a/src/components/EmptyState/__stories__/assets/empty_state_img.svg b/src/components/EmptyState/__stories__/assets/empty_state_img.svg new file mode 100644 index 0000000000..0165a9a974 --- /dev/null +++ b/src/components/EmptyState/__stories__/assets/empty_state_img.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/EmptyState/__tests__/__snapshots__/emptyState-snapshot-tests.jest.js.snap b/src/components/EmptyState/__tests__/__snapshots__/emptyState-snapshot-tests.jest.js.snap new file mode 100644 index 0000000000..5b7e92f6ea --- /dev/null +++ b/src/components/EmptyState/__tests__/__snapshots__/emptyState-snapshot-tests.jest.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmptyState should render correctly with required props 1`] = ` +
+ This is title +
+

+ This is title +

+
+ This is body +
+
+
+
+`; diff --git a/src/components/EmptyState/__tests__/emptyState-snapshot-tests.jest.js b/src/components/EmptyState/__tests__/emptyState-snapshot-tests.jest.js new file mode 100644 index 0000000000..0f03c08d29 --- /dev/null +++ b/src/components/EmptyState/__tests__/emptyState-snapshot-tests.jest.js @@ -0,0 +1,16 @@ +import React from "react"; +import renderer from "react-test-renderer"; +import EmptyState from "../EmptyState"; + +const defaultProps = { + imgSrc: "someImg", + title: "This is title", + body: "This is body" +}; + +describe("EmptyState", () => { + it("should render correctly with required props", () => { + const tree = renderer.create().toJSON(); + expect(tree).toMatchSnapshot(); + }); +}); diff --git a/src/components/EmptyState/__tests__/emptyState-tests.jest.tsx b/src/components/EmptyState/__tests__/emptyState-tests.jest.tsx new file mode 100644 index 0000000000..442e95f83a --- /dev/null +++ b/src/components/EmptyState/__tests__/emptyState-tests.jest.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render } from "@testing-library/react"; +import EmptyState, { EmptyStateProps } from "../EmptyState"; + +const defaultProps = { + imgSrc: "someImg", + title: "This is title", + body: "This is body" +}; + +const renderComponent = (props: Partial = {}) => { + return render(); +}; + +describe("EmptyState", () => { + describe("props sanity", () => { + it("should render different title", () => { + const { getByText } = renderComponent({ title: "different title" }); + expect(getByText("different title")).toBeInTheDocument(); + }); + + it("should render different body", () => { + const { getByText } = renderComponent({ body: "different body" }); + expect(getByText("different body")).toBeInTheDocument(); + }); + + it("should render different image", () => { + const { getByAltText, getByRole } = renderComponent({ imgSrc: "different image" }); + expect(getByRole("img")).toHaveAttribute("src", "different image"); + expect(getByAltText("This is title")).toBeInTheDocument(); + }); + }); + + describe("actions", () => { + describe("rendering", () => { + it("should not render action buttons", () => { + const { queryByRole } = renderComponent(); + expect(queryByRole("button")).toBeFalsy(); + }); + + it("should render primary button", () => { + const { getByText, getAllByRole } = renderComponent({ + primaryActionLabel: "primary", + onPrimaryActionClick: jest.fn() + }); + expect(getAllByRole("button")).toHaveLength(1); + expect(getByText("primary")).toBeInTheDocument(); + }); + + it("should render primary and secondary buttons", () => { + const { getByText, getAllByRole } = renderComponent({ + primaryActionLabel: "primary", + onPrimaryActionClick: jest.fn(), + secondaryActionLabel: "secondary", + onSecondaryActionClick: jest.fn() + }); + expect(getAllByRole("button")).toHaveLength(2); + expect(getByText("primary")).toBeInTheDocument(); + expect(getByText("secondary")).toBeInTheDocument(); + }); + }); + + describe("functionality", () => { + const primaryActionMock = jest.fn(); + const secondaryActionMock = jest.fn(); + const renderComponentWithActions = () => + renderComponent({ + primaryActionLabel: "primary", + onPrimaryActionClick: primaryActionMock, + secondaryActionLabel: "secondary", + onSecondaryActionClick: secondaryActionMock + }); + + afterEach(() => { + primaryActionMock.mockClear(); + secondaryActionMock.mockClear(); + }); + + it("should call onPrimaryActionClick once", () => { + const { getByText } = renderComponentWithActions(); + fireEvent.click(getByText("primary")); + expect(primaryActionMock).toHaveBeenCalledTimes(1); + expect(secondaryActionMock).toHaveBeenCalledTimes(0); + }); + + it("should call onSecondaryActionClick once", () => { + const { getByText } = renderComponentWithActions(); + fireEvent.click(getByText("secondary")); + expect(secondaryActionMock).toHaveBeenCalledTimes(1); + expect(primaryActionMock).toHaveBeenCalledTimes(0); + }); + }); + }); +}); diff --git a/src/components/index.js b/src/components/index.js index bf71663233..a4deb9ef71 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -95,3 +95,4 @@ export { GridKeyboardNavigationContext } from "./GridKeyboardNavigationContext/GridKeyboardNavigationContext"; export { default as Badge } from "./Badge/Badge"; +export { default as EmptyState } from "./EmptyState/EmptyState"; diff --git a/src/tests/constants.ts b/src/tests/constants.ts index 188c6bb204..ec58a11ced 100644 --- a/src/tests/constants.ts +++ b/src/tests/constants.ts @@ -1,6 +1,7 @@ export enum ComponentDefaultTestId { // Don't remove next line // plop_marker:default-data-testid-declarations + EMPTY_STATE = "empty-state", INDICATOR = "indicator", BADGE = "badge", TITLE = "title", diff --git a/webpack/published-ts-components.js b/webpack/published-ts-components.js index ead4b5fd7e..c93c7aacdc 100644 --- a/webpack/published-ts-components.js +++ b/webpack/published-ts-components.js @@ -1,6 +1,7 @@ const publishedTSComponents = { // Don't remove next line // plop_marker:published-components + EmptyState: "components/EmptyState/EmptyState", Badge: "components/Badge/Badge", Text: "components/Text/Text", Button: "components/Button/Button",