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: APP-2715 - Implement EmptyState Component #77

Merged
merged 36 commits into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
75821b7
wip: basic frameout for emptycard design spec
thekidnamedkd Jan 30, 2024
5eecda3
chore: refactor types + write default story
thekidnamedkd Jan 30, 2024
189d496
wip: begin defining props and data
thekidnamedkd Jan 31, 2024
5364625
feat: layout emptystate component with isStacked views and breakpoint…
thekidnamedkd Jan 31, 2024
d002d59
wip: fixing types to spec
thekidnamedkd Jan 31, 2024
788f2f7
chore: improve types & hinting for DX impl
thekidnamedkd Feb 1, 2024
018c9f0
chore: remove test component from card
thekidnamedkd Feb 1, 2024
1d14ef8
feat: improve types and variant hinting for buttons
thekidnamedkd Feb 1, 2024
93e2ae0
Merge branch 'main' of github.com:aragon/ods into feat/APP-2715
thekidnamedkd Feb 1, 2024
10435d1
chore: update CHANGELOG
thekidnamedkd Feb 1, 2024
9c06a79
chore: write tests for EmptyState component
thekidnamedkd Feb 1, 2024
3a1a96d
chore: clean up stories for type changes
thekidnamedkd Feb 1, 2024
49baa6d
chore: improve story titles, remove card
thekidnamedkd Feb 1, 2024
b96e3d1
chore: improve default story for both illustration types w/ control
thekidnamedkd Feb 1, 2024
e66f15d
chore: improve default story for both illustration types w/ control
thekidnamedkd Feb 1, 2024
84763a2
chore: resolve FF issue
thekidnamedkd Feb 1, 2024
a73116a
chore: resolve merge conflict for CHANGELOG
thekidnamedkd Feb 1, 2024
172217f
fix: linter issue for type warning
thekidnamedkd Feb 1, 2024
f3e5cee
fix: add conditions for button and illustration for extra render safety
thekidnamedkd Feb 2, 2024
50b11de
fix: update component api for extendability
thekidnamedkd Feb 2, 2024
981feb6
fix: update API + tests + stories for PR conversation
thekidnamedkd Feb 5, 2024
f2428b7
fix: switch title to heading for upcoming card conflict, additional c…
thekidnamedkd Feb 5, 2024
cb6bdae
feat: introduce primary button for all layouts
thekidnamedkd Feb 5, 2024
77ae381
feat: handle button wrapping for larger labels at smaller views
thekidnamedkd Feb 5, 2024
a4c0289
fix: remove unnecessary test for primary button due to spec change
thekidnamedkd Feb 5, 2024
3489d5d
fix: update API + tests + stories for PR conversation
thekidnamedkd Feb 6, 2024
7958272
fix: remove illustration visual testing
thekidnamedkd Feb 6, 2024
c892f52
fix: import paths & comments
thekidnamedkd Feb 6, 2024
207ff15
chore: center illustration, add story note for human
thekidnamedkd Feb 7, 2024
2ecad6e
fix: proper exports for library, test fixes, API improvement
thekidnamedkd Feb 7, 2024
b255c9c
Merge branch 'main' of github.com:aragon/ods into feat/APP-2715
thekidnamedkd Feb 7, 2024
022120e
fix: move API refactor to button to avoid named conflict
thekidnamedkd Feb 7, 2024
fca2941
fix: minimize test reqs, remove casting
thekidnamedkd Feb 7, 2024
f7a49e6
fix: update CHANGELOG (moved to unreleased)
thekidnamedkd Feb 7, 2024
b008196
chore: remove extraneous render functions from stories for EmptyState
thekidnamedkd Feb 7, 2024
63d8300
fix: resolve CHANGELOG merge conflict
thekidnamedkd Feb 7, 2024
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `Checkbox`, `CheckboxGroup`, `RadioGroup`, `Radio`, and `RadioCard` components
- Implement `EmptyState`, `Checkbox`, `CheckboxGroup`, `RadioGroup`, `Radio`, and `RadioCard` components

## [1.0.11] - 2024-02-06

Expand Down
5 changes: 3 additions & 2 deletions src/components/button/button.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,6 @@ export interface IButtonBaseProps {
iconLeft?: IconType;
}

export type IButtonProps = IButtonBaseProps &
(ButtonHTMLAttributes<HTMLButtonElement> | AnchorHTMLAttributes<HTMLAnchorElement>);
export type IButtonElementProps = ButtonHTMLAttributes<HTMLButtonElement> | AnchorHTMLAttributes<HTMLAnchorElement>;

export type IButtonProps = IButtonBaseProps & IButtonElementProps;
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './input';
export * from './progress';
export * from './radioGroup';
export * from './spinner';
export * from './states';
export * from './switch';
export * from './tag';
export * from './textAreas';
Expand Down
56 changes: 56 additions & 0 deletions src/components/states/emptyState/emptyState.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { IButtonBaseProps, IButtonElementProps } from '../../button/button.api';
import type { IIllustrationHumanProps, IIllustrationObjectProps } from '../../illustrations';

export interface IEmptyStateBaseProps {
/**
* Title of the empty state.
*/
heading: string;
/**
* Description of the empty state.
*/
description?: string;
/**
* Renders the state as horitontal when set to false.
* @default true
*/
isStacked?: boolean;
/**
* Primary button of the empty state. The primary button is only rendered on the stacked variant.
*/
primaryButton?: IEmptyStateButton;
/**
* Secondary button of the empty state.
*/
secondaryButton?: IEmptyStateButton;
/**
* Additional class names to be added to the empty state.
*/
className?: string;
}

export type IEmptyStateButton = Omit<IButtonBaseProps, 'variant' | 'size' | 'children'> &
IButtonElementProps & {
/**
* Button label to be rendered.
*/
label: string;
};

export interface IEmptyStateHumanIllustrationProps extends IEmptyStateBaseProps {
/**
* @see IIllustrationHumanProps
*/
humanIllustration: IIllustrationHumanProps;
objectIllustration?: never;
}

export interface IEmptyStateObjectIllustrationProps extends IEmptyStateBaseProps {
/**
* @see IIllustrationObjectProps
*/
objectIllustration: IIllustrationObjectProps;
humanIllustration?: never;
}

export type IEmptyStateProps = IEmptyStateHumanIllustrationProps | IEmptyStateObjectIllustrationProps;
106 changes: 106 additions & 0 deletions src/components/states/emptyState/emptyState.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react';
import { IconType } from '../../icon';
import { EmptyState } from './emptyState';

const meta: Meta<typeof EmptyState> = {
title: 'components/States/EmptyState',
component: EmptyState,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?type=design&node-id=10095%3A21633&mode=dev&t=FtMO7nBXAzYBFGaW-1',
},
},
};

type Story = StoryObj<typeof EmptyState>;

/**
* Default EmptyState component with minimum props.
*/
export const Default: Story = {
args: {
heading: 'Heading',
description: 'Description',
objectIllustration: { object: 'LIGHTBULB' },
},
};

/**
* Stacked EmptyState component with full props examples for Object Illustration.
*/
export const StackedFullWithObject: Story = {
args: {
heading: 'Heading',
description: 'Description',
objectIllustration: { object: 'LIGHTBULB' },
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};
/**
* Non-Stacked EmptyState component with full props examples for Object Illustration. <br />
* **Warning:** Non-Stacked EmptyState with Human Illustration is not supported visually.
* As displayed, use an object illustration instead for best layout.
*/
export const NonStackedFullWithObject: Story = {
args: {
heading: 'Heading',
description: 'Description',
isStacked: false,
objectIllustration: { object: 'LIGHTBULB' },
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};
/**
* Stacked EmptyState component with full props examples for Human Illustation.
*/
export const StackedFullWithHuman: Story = {
args: {
heading: 'Heading',
description: 'Description',
humanIllustration: {
body: 'VOTING',
hairs: 'MIDDLE',
accessory: 'EARRINGS_RHOMBUS',
sunglasses: 'BIG_ROUNDED',
expression: 'SMILE',
},
primaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Primary Button Clicked'),
},
secondaryButton: {
label: 'Label',
iconLeft: IconType.ADD,
iconRight: IconType.CHEVRON_RIGHT,
onClick: () => alert('Secondary Button Clicked'),
},
},
};

export default meta;
72 changes: 72 additions & 0 deletions src/components/states/emptyState/emptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react';
import { EmptyState } from './emptyState';
import type { IEmptyStateProps } from './emptyState.api';

describe('<EmptyState /> component', () => {
const createTestComponent = (props?: Partial<IEmptyStateProps>) => {
const commonProps = {
heading: 'test-heading',
};

if (props?.humanIllustration) {
return <EmptyState humanIllustration={props.humanIllustration} {...commonProps} {...props} />;
} else {
const { humanIllustration, objectIllustration = { object: 'ACTION' }, ...otherProps } = props ?? {};

return <EmptyState objectIllustration={objectIllustration} {...commonProps} {...otherProps} />;
}
};

it('renders the EmptyState component stacked with full props and object illustration', () => {
const objectIllustration = { object: 'LIGHTBULB' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
render(createTestComponent({ objectIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
expect(screen.getByTestId('LIGHTBULB')).toBeInTheDocument();
});

it('renders the EmptyState component stacked with full props and human illustration', () => {
const humanIllustration = { body: 'VOTING', expression: 'SMILE' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
render(createTestComponent({ humanIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
expect(screen.getByTestId('VOTING')).toBeInTheDocument();
});

it('renders the EmptyState component unstacked with full props and object illustration', () => {
const objectIllustration = { object: 'LIGHTBULB' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
const isStacked = false;
render(createTestComponent({ isStacked, objectIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
const objectImage = screen.getByTestId('LIGHTBULB');
expect(objectImage).toHaveClass('order-last');
});

it('renders the EmptyState component unstacked with full props and human illustration', () => {
const humanIllustration = { body: 'VOTING', expression: 'SMILE' } as const;
const primaryButton = { label: 'Label' };
const secondaryButton = { label: 'Label' };
const isStacked = false;
render(createTestComponent({ isStacked, humanIllustration, primaryButton, secondaryButton }));

expect(screen.getByText('test-heading')).toBeInTheDocument();
const buttons = screen.getAllByRole('button', { name: 'Label' });
expect(buttons).toHaveLength(2);
const humanImage = screen.getByTestId('VOTING');
// eslint-disable-next-line testing-library/no-node-access -- testid for SVG is nearest accessible attribute and reliable to the illustration
expect(humanImage.parentElement).toHaveClass('order-last');
});
});
109 changes: 109 additions & 0 deletions src/components/states/emptyState/emptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import classNames from 'classnames';
thekidnamedkd marked this conversation as resolved.
Show resolved Hide resolved
import { Button } from '../../button';
import { IllustrationHuman, IllustrationObject } from '../../illustrations';
import type { IEmptyStateProps } from './emptyState.api';

export const EmptyState: React.FC<IEmptyStateProps> = ({
heading,
description,
primaryButton,
secondaryButton,
className,
isStacked = true,
objectIllustration,
humanIllustration,
}) => {
const containerClassNames = classNames(
'grid w-[320px] md:w-[640px]',
{ 'grid-cols-1 justify-items-center p-6 md:p-12 gap-4 md:gap-6': isStacked },
{
'grid-cols-[auto_max-content] md:grid-cols-[auto_max-content] gap-4 p-4 md:px-6 md:py-5 items-center':
!isStacked,
},
className,
);

return (
<div className={containerClassNames}>
{humanIllustration && (
<IllustrationHuman
className={classNames({
'mb-4 h-auto !w-[295px] md:mb-6 md:!w-[400px]': isStacked,
'align-self-center order-last !w-[80px] justify-self-end md:!w-[172px]': !isStacked,
})}
{...humanIllustration}
/>
)}
{objectIllustration && (
<IllustrationObject
className={classNames({
'h-auto w-[160px]': isStacked,
'order-last h-auto w-[80px] justify-self-end rounded-full bg-neutral-50 md:w-[96px]':
!isStacked,
})}
{...objectIllustration}
/>
)}

<div
className={classNames('h-full', {
'flex w-full flex-col items-center': isStacked,
'space-y-6': (isStacked && !!primaryButton) || !!secondaryButton,
'space-y-4': (!isStacked && !!primaryButton) || !!secondaryButton,
})}
>
<div
className={classNames({
'flex flex-col items-center space-y-1 md:space-y-2': isStacked,
'items-start space-y-0.5 md:space-y-1': !isStacked,
})}
>
<p
className={classNames('font-normal leading-tight text-neutral-800', {
'text-xl md:text-2xl': isStacked,
'text-base md:text-lg': !isStacked,
})}
>
{heading}
</p>
<p
className={classNames('font-normal leading-tight text-neutral-500', {
'text-sm md:text-base': isStacked,
'text-xs md:text-sm': !isStacked,
})}
>
{description}
</p>
</div>
<div
className={classNames({
'border-w-full flex flex-col items-stretch space-x-0 space-y-3 md:flex-row md:justify-center md:space-x-4 md:space-y-0':
isStacked,
'flex flex-row flex-wrap gap-3': !isStacked,
})}
>
{primaryButton && (
<Button
{...primaryButton}
size={isStacked ? 'lg' : 'sm'}
responsiveSize={isStacked ? { md: 'lg' } : { md: 'md' }}
variant="primary"
>
{primaryButton.label}
</Button>
)}
{secondaryButton && (
<Button
{...secondaryButton}
size={isStacked ? 'lg' : 'sm'}
responsiveSize={isStacked ? { md: 'lg' } : { md: 'md' }}
variant="secondary"
>
{secondaryButton.label}
</Button>
)}
</div>
</div>
</div>
);
};
8 changes: 8 additions & 0 deletions src/components/states/emptyState/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { EmptyState } from './emptyState';
export type {
IEmptyStateBaseProps,
IEmptyStateButton,
IEmptyStateHumanIllustrationProps,
IEmptyStateObjectIllustrationProps,
IEmptyStateProps,
} from './emptyState.api';
1 change: 1 addition & 0 deletions src/components/states/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './emptyState';
Loading