Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(EmptyState): new component #1514

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

an empty state would also need support SVG, Lottie and maybe at some point different assets. why not keep this as a general node? this would also make imgClassName redundant

title: string;
body: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if body is the best name for it... Maybe just text, message or smth else?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

content?

onPrimaryActionClick?: () => void;
primaryActionLabel?: string;
Comment on lines +18 to +19
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the abstraction to have very simple actionable button props, but take into consideration that if in the future some a11y props will be needed, we might want to pass the entire props easily primaryActionProps

onSecondaryActionClick?: () => void;
secondaryActionLabel?: string;
imgClassName?: string;
Comment on lines +15 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some description comments would be nice

}

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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always one line here by design?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a concern about what's gonna happen if people put a lot of text here - attaching screenshots to demonstrate
image
image
image

We should think about some max-width limitations, so the text will just go ellipsis with a tooltip when it's reaching the limits

Comment on lines +58 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

general suggestion - apply Heading and Text only when string type is passed, so in cases where other content will be passed, it will not always be wrapped with the typography component constaraints

Comment on lines +58 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would apply ellipsis by default if I understand correctly, not sure this is the expected behavior for the empty state

</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>}
Comment on lines +62 to +67
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, but people might use secondaryButton without the primary one, which won't be good. Perhaps, we can protect from this from the beginning

</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