-
Notifications
You must be signed in to change notification settings - Fork 322
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.emptyState { | ||
.image { | ||
pointer-events: none; | ||
user-select: none; | ||
} | ||
} |
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. content? |
||
onPrimaryActionClick?: () => void; | ||
primaryActionLabel?: string; | ||
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
onSecondaryActionClick?: () => void; | ||
secondaryActionLabel?: string; | ||
imgClassName?: string; | ||
Comment on lines
+15
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Always one line here by design? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+58
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. general suggestion - apply
Comment on lines
+58
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; |
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" /> |
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(); | ||
}); | ||
}); |
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); | ||
}); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
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 makeimgClassName
redundant