Skip to content

Commit

Permalink
feat(EmptyState): new component
Browse files Browse the repository at this point in the history
  • Loading branch information
YossiSaadi committed Aug 31, 2023
1 parent ee2970b commit a67ccbc
Show file tree
Hide file tree
Showing 10 changed files with 297 additions and 0 deletions.
6 changes: 6 additions & 0 deletions src/components/EmptyState/EmptyState.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.emptyState {
.image {
pointer-events: none;
user-select: none;
}
}
74 changes: 74 additions & 0 deletions src/components/EmptyState/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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<EmptyStateProps, HTMLElement> = 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 (
<Flex
ref={mergedRef}
className={cx(styles.emptyState, className)}
direction={Flex.directions.COLUMN}
align={Flex.align.CENTER}
justify={Flex.justify.CENTER}
gap={Flex.gaps.LARGE}
id={id}
data-testid={dataTestId || getTestId(ComponentDefaultTestId.EMPTY_STATE, id)}
>
<img src={imgSrc} alt={title} className={cx(styles.image, imgClassName)} />
<Flex direction={Flex.directions.COLUMN} gap={Flex.gaps.SMALL} align={Flex.align.CENTER}>
<Heading type={Heading.types.H2}>{title}</Heading>
<Text type={Text.types.TEXT1}>{body}</Text>
</Flex>
<Flex gap={Flex.gaps.SMALL} align={Flex.align.CENTER}>
{secondaryActionLabel && (
<Button kind={Button.kinds.TERTIARY} onClick={onSecondaryActionClick}>
{secondaryActionLabel}
</Button>
)}
{primaryActionLabel && <Button onClick={onPrimaryActionClick}>{primaryActionLabel}</Button>}
</Flex>
</Flex>
);
}
);

export default EmptyState;
54 changes: 54 additions & 0 deletions src/components/EmptyState/__stories__/EmptyState.stories.mdx
Original file line number Diff line number Diff line change
@@ -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"]
});

<Meta
title="Feedback/EmptyState"
component={metaSettings.component}
argTypes={metaSettings.argTypes}
decorators={metaSettings.decorators}
/>

<!--- Component template -->

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"
};

<!--- Component documentation -->

# 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.

<Canvas>
<Story name="Overview" args={emptyStateTemplateDefaults}>
{emptyStateTemplate.bind({})}
</Story>
</Canvas>

## Props

<ArgsTable story="Overview" />
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`EmptyState should render correctly with required props 1`] = `
<div
className="container directionColumn justifyCenter alignCenter emptyState"
data-testid="empty-state"
style={
Object {
"gap": "24px",
}
}
>
<img
alt="This is title"
className="image"
src="someImg"
/>
<div
className="container directionColumn justifyStart alignCenter"
style={
Object {
"gap": "8px",
}
}
>
<h2
className="typography primary start singleLineEllipsis heading h2Normal"
data-testid="text"
>
This is title
</h2>
<div
className="typography primary start singleLineEllipsis text text1Normal"
data-testid="text"
>
This is body
</div>
</div>
<div
className="container directionRow justifyStart alignCenter"
style={
Object {
"gap": "8px",
}
}
/>
</div>
`;
Original file line number Diff line number Diff line change
@@ -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(<EmptyState {...defaultProps} />).toJSON();
expect(tree).toMatchSnapshot();
});
});
95 changes: 95 additions & 0 deletions src/components/EmptyState/__tests__/emptyState-tests.jest.tsx
Original file line number Diff line number Diff line change
@@ -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<EmptyStateProps> = {}) => {
return render(<EmptyState {...defaultProps} {...props} />);
};

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);
});
});
});
});
1 change: 1 addition & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,4 @@ export {
GridKeyboardNavigationContext
} from "./GridKeyboardNavigationContext/GridKeyboardNavigationContext";
export { default as Badge } from "./Badge/Badge";
export { default as EmptyState } from "./EmptyState/EmptyState";
1 change: 1 addition & 0 deletions src/tests/constants.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
1 change: 1 addition & 0 deletions webpack/published-ts-components.js
Original file line number Diff line number Diff line change
@@ -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",
Expand Down

0 comments on commit a67ccbc

Please sign in to comment.