Unverified Commit b2230452 authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Empty and Loading states for the table (#639)

Signed-off-by: 's avatarMarcos Iglesias Valle <golodhros@gmail.com>
parent 97a5e14f
...@@ -16,7 +16,7 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1); ...@@ -16,7 +16,7 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1);
} }
} }
.is-shimmer-animated { %is-shimmer-animated {
animation: $loading-duration shimmer $loading-curve infinite; animation: $loading-duration shimmer $loading-curve infinite;
background-image: linear-gradient( background-image: linear-gradient(
to right, to right,
...@@ -29,3 +29,7 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1); ...@@ -29,3 +29,7 @@ $loading-curve: cubic-bezier(0.45, 0, 0.15, 1);
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: 300% 100%; background-size: 300% 100%;
} }
.is-shimmer-animated {
@extend %is-shimmer-animated;
}
...@@ -30,6 +30,83 @@ describe('Table', () => { ...@@ -30,6 +30,83 @@ describe('Table', () => {
}).not.toThrow(); }).not.toThrow();
}); });
describe('when empty data is passed', () => {
const { columns, data } = dataBuilder.withEmptyData().build();
it('renders a table', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find('.ams-table').length;
expect(actual).toEqual(expected);
});
describe('table header', () => {
it('renders a table header', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find('.ams-table-header').length;
expect(actual).toEqual(expected);
});
it('renders one cell inside the header', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find(
'.ams-table-header .ams-table-heading-cell'
).length;
expect(actual).toEqual(expected);
});
});
describe('table body', () => {
it('renders a table body', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find('.ams-table-body').length;
expect(actual).toEqual(expected);
});
it('renders one row', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find('.ams-table-row').length;
expect(actual).toEqual(expected);
});
it('renders an empty message', () => {
const { wrapper } = setup({
data,
columns,
});
const expected = 1;
const actual = wrapper.find('.ams-table-row .ams-empty-message-cell')
.length;
expect(actual).toEqual(expected);
});
});
});
describe('when simple data is passed', () => { describe('when simple data is passed', () => {
it('renders a table', () => { it('renders a table', () => {
const { wrapper } = setup(); const { wrapper } = setup();
...@@ -119,6 +196,137 @@ describe('Table', () => { ...@@ -119,6 +196,137 @@ describe('Table', () => {
}); });
}); });
}); });
describe('options', () => {
describe('when a tableClassName is passed', () => {
it('adds the class to the table', () => {
const { wrapper } = setup({
options: { tableClassName: 'test-class' },
});
const expected = 1;
const actual = wrapper.find('.test-class').length;
expect(actual).toEqual(expected);
});
});
describe('when isLoading is active', () => {
it('renders a table', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: 10,
},
});
const expected = 1;
const actual = wrapper.find('.ams-table').length;
expect(actual).toEqual(expected);
});
describe('table header', () => {
it('renders a table header', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
},
});
const expected = 1;
const actual = wrapper.find('.ams-table-header').length;
expect(actual).toEqual(expected);
});
it('renders one cell inside the header', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: 10,
},
});
const expected = 1;
const actual = wrapper.find(
'.ams-table-header .ams-table-heading-loading-cell'
).length;
expect(actual).toEqual(expected);
});
it('renders one loading block inside the header', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: 10,
},
});
const expected = 1;
const actual = wrapper.find(
'.ams-table-header .ams-table-shimmer-block'
).length;
expect(actual).toEqual(expected);
});
});
describe('table body', () => {
it('renders a table body', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: 10,
},
});
const expected = 1;
const actual = wrapper.find('.ams-table-body').length;
expect(actual).toEqual(expected);
});
it('renders one row', () => {
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: 10,
},
});
const expected = 1;
const actual = wrapper.find('.ams-table-row').length;
expect(actual).toEqual(expected);
});
it('renders the proper number of shimmering blocks', () => {
const numOfLoadingBlocks = 10;
const { wrapper } = setup({
data: [],
columns: [],
options: {
isLoading: true,
numLoadingBlocks: numOfLoadingBlocks,
},
});
const expected = numOfLoadingBlocks;
const actual = wrapper.find(
'.ams-table-row .shimmer-resource-loader-item'
).length;
expect(actual).toEqual(expected);
});
});
});
});
}); });
describe('lifetime', () => {}); describe('lifetime', () => {});
......
...@@ -3,55 +3,121 @@ ...@@ -3,55 +3,121 @@
import * as React from 'react'; import * as React from 'react';
import ShimmeringResourceLoader from '../ShimmeringResourceLoader';
import './styles.scss'; import './styles.scss';
export interface TableColumn { export interface TableColumn {
title: string; title: string;
field: string; field: string;
horAlign?: 'left' | 'right' | 'center';
// width?: number;
// className?: string; // className?: string;
// horAlign?: 'left' | 'right' | 'center';
// width?: number;
// sortable?: bool (false) // sortable?: bool (false)
// data?: () => React.ReactNode ((row,index) => <div>{index}</div>) // data?: () => React.ReactNode ((row,index) => <div>{index}</div>)
// actions?: Action[] // actions?: Action[]
} }
export interface TableOptions {
tableClassName?: string;
isLoading?: boolean;
numLoadingBlocks?: number;
}
export interface TableProps { export interface TableProps {
columns: TableColumn[]; columns: TableColumn[];
data: []; data: [];
options?: TableOptions;
} }
const Table: React.FC<TableProps> = ({ data, columns }: TableProps) => { const DEFAULT_EMPTY_MESSAGE = 'No Results';
const DEFAULT_LOADING_ITEMS = 3;
type EmptyRowProps = {
colspan: number;
};
const EmptyRow: React.FC<EmptyRowProps> = ({ colspan }: EmptyRowProps) => (
<tr className="ams-table-row">
<td className="ams-empty-message-cell" colSpan={colspan}>
{DEFAULT_EMPTY_MESSAGE}
</td>
</tr>
);
const ShimmeringHeader: React.FC = () => (
<tr>
<th className="ams-table-heading-loading-cell">
<div className="ams-table-shimmer-block" />
</th>
</tr>
);
type ShimmeringBodyProps = {
numLoadingBlocks: number;
};
const ShimmeringBody: React.FC<ShimmeringBodyProps> = ({
numLoadingBlocks,
}: ShimmeringBodyProps) => (
<tr className="ams-table-row">
<td className="ams-table-body-loading-cell">
<ShimmeringResourceLoader numItems={numLoadingBlocks} />
</td>
</tr>
);
const Table: React.FC<TableProps> = ({
data,
columns,
options = {},
}: TableProps) => {
const {
tableClassName = '',
isLoading = false,
numLoadingBlocks = DEFAULT_LOADING_ITEMS,
} = options;
const fields = columns.map(({ field }) => field); const fields = columns.map(({ field }) => field);
return ( let body: React.ReactNode = <EmptyRow colspan={fields.length} />;
<table className="ams-table">
<thead className="ams-table-header"> if (data.length) {
<tr> body = data.map((item, index) => {
{columns.map(({ title }, index) => { return (
return ( <tr className="ams-table-row" key={`index:${index}`}>
<th className="ams-table-heading-cell" key={`index:${index}`}> {Object.entries(item)
{title} .filter(([key]) => fields.includes(key))
</th> .map(([, value], index) => (
); <td className="ams-table-cell" key={`index:${index}`}>
})} {value}
</td>
))}
</tr> </tr>
</thead> );
<tbody className="ams-table-body"> });
{data.map((item, index) => { }
return (
<tr className="ams-table-row" key={`index:${index}`}> let header: React.ReactNode = (
{Object.entries(item) <tr>
.filter(([key]) => fields.includes(key)) {columns.map(({ title }, index) => {
.map(([, value], index) => ( return (
<td className="ams-table-cell" key={`index:${index}`}> <th className="ams-table-heading-cell" key={`index:${index}`}>
{value} {title}
</td> </th>
))} );
</tr> })}
); </tr>
})} );
</tbody>
if (isLoading) {
header = <ShimmeringHeader />;
body = <ShimmeringBody numLoadingBlocks={numLoadingBlocks} />;
}
return (
<table className={`ams-table ${tableClassName || ''}`}>
<thead className="ams-table-header">{header}</thead>
<tbody className="ams-table-body">{body}</tbody>
</table> </table>
); );
}; };
......
// Copyright Contributors to the Amundsen project.
// SPDX-License-Identifier: Apache-2.0
@import 'variables'; @import 'variables';
@import 'typography'; @import 'typography';
@import 'animations';
$table-header-font-size: 12px; $table-header-font-size: 12px;
$table-header-line-height: 16px; $table-header-line-height: 16px;
$table-header-height: 33px; $table-header-height: 33px;
$table-header-border-width: 2px; $table-header-border-width: 2px;
$shimmer-block-height: 16px;
$shimmer-block-width: 40%;
.ams-table { .ams-table {
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
...@@ -24,3 +31,29 @@ $table-header-border-width: 2px; ...@@ -24,3 +31,29 @@ $table-header-border-width: 2px;
height: $table-header-height; height: $table-header-height;
text-transform: uppercase; text-transform: uppercase;
} }
.ams-empty-message-cell {
@extend %text-body-w3;
color: $text-primary;
text-align: center;
}
.ams-table-heading-loading-cell {
padding: $spacer-1;
}
.ams-table-body-loading-cell {
padding: 0 $spacer-1;
.shimmer-resource-loader {
margin-top: -1px;
}
}
.ams-table-shimmer-block {
@extend %is-shimmer-animated;
height: $shimmer-block-height;
width: $shimmer-block-width;
}
...@@ -17,5 +17,11 @@ stories.add('Table', () => ( ...@@ -17,5 +17,11 @@ stories.add('Table', () => (
<StorySection title="Basic Table"> <StorySection title="Basic Table">
<Table columns={columns} data={data} /> <Table columns={columns} data={data} />
</StorySection> </StorySection>
<StorySection title="Empty Table">
<Table columns={columns} data={[]} />
</StorySection>
<StorySection title="Loading Table">
<Table columns={[]} data={[]} options={{ isLoading: true }} />
</StorySection>
</> </>
)); ));
...@@ -50,6 +50,15 @@ function TestDataBuilder(config = {}) { ...@@ -50,6 +50,15 @@ function TestDataBuilder(config = {}) {
return new this.Klass(attr); return new this.Klass(attr);
}; };
this.withEmptyData = () => {
const attr = {
data: [],
columns: [{ title: 'Name', field: 'name' }],
};
return new this.Klass(attr);
};
this.withMoreDataThanColumns = () => { this.withMoreDataThanColumns = () => {
const attr = { const attr = {
data: [ data: [
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment