Unverified Commit 14cf1ff3 authored by Marcos Iglesias's avatar Marcos Iglesias Committed by GitHub

feat: Update ListItem Loading State into Shimmering loader (#484)

* Adds shimmer loader to Bookmarks

* Adds loader for popular tables

* Shimmering loader on results page

* Abstracting Shimering Resource Loader

* Adding tests

* Formatting

* Reusing RESULTS_PER_PAGE on the number of shimmering row items

* Reusing RESULTS_PER_PAGE on the number of shimmering row items
parent 7fdaf8db
export const SEARCH_BREADCRUMB_TEXT = 'Advanced Search';
export const HOMEPAGE_TITLE = 'Amundsen Homepage';
export const TAGS_TITLE = 'Browse Tags';
......@@ -13,7 +13,11 @@ import { resetSearchState } from 'ducks/search/reducer';
import { UpdateSearchStateReset } from 'ducks/search/types';
import SearchBar from 'components/common/SearchBar';
import TagsList from 'components/common/TagsList';
import { SEARCH_BREADCRUMB_TEXT, HOMEPAGE_TITLE } from './constants';
import {
SEARCH_BREADCRUMB_TEXT,
HOMEPAGE_TITLE,
TAGS_TITLE,
} from './constants';
export interface DispatchFromProps {
searchReset: () => UpdateSearchStateReset;
......@@ -45,7 +49,7 @@ export class HomePage extends React.Component<HomePageProps> {
id="browse-tags-header"
className="title-1 browse-tags-header"
>
Browse Tags
{TAGS_TITLE}
</div>
<TagsList />
</div>
......
......@@ -6,7 +6,6 @@ import { shallow } from 'enzyme';
import { ResourceType } from 'interfaces';
import LoadingSpinner from 'components/common/LoadingSpinner';
import ResourceSelector from 'components/SearchPage/ResourceSelector';
import SearchFilter from 'components/SearchPage/SearchFilter';
import SearchPanel from 'components/SearchPage/SearchPanel';
......@@ -37,12 +36,10 @@ import {
SearchPageProps,
} from '.';
describe('SearchPage', () => {
const setStateSpy = jest.spyOn(SearchPage.prototype, 'setState');
const setup = (
const setup = (
propOverrides?: Partial<SearchPageProps>,
location?: Partial<History.Location>
) => {
) => {
const routerProps = getMockRouterProps<any>(null, location);
const props: SearchPageProps = {
hasFilters: false,
......@@ -59,17 +56,17 @@ describe('SearchPage', () => {
};
const wrapper = shallow<SearchPage>(<SearchPage {...props} />);
return { props, wrapper };
};
};
describe('SearchPage', () => {
describe('componentDidMount', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup(null, {
({ props, wrapper } = setup(null, {
search: '/search?searchTerm=testName&resource=table&pageIndex=1',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
}));
});
it('calls getUrlParams and getGlobalStateParams', () => {
......@@ -81,15 +78,12 @@ describe('SearchPage', () => {
describe('componentDidUpdate', () => {
let props;
let wrapper;
let mockPrevProps;
beforeAll(() => {
const setupResult = setup(null, {
({ props, wrapper } = setup(null, {
search: '/search?searchTerm=testName&resource=table&pageIndex=1',
});
props = setupResult.props;
wrapper = setupResult.wrapper;
}));
mockPrevProps = {
searchTerm: 'previous',
location: {
......@@ -116,8 +110,9 @@ describe('SearchPage', () => {
describe('generateTabLabel', () => {
let wrapper;
beforeAll(() => {
wrapper = setup().wrapper;
({ wrapper } = setup());
});
it('returns correct text for ResourceType.table', () => {
......@@ -171,7 +166,7 @@ describe('SearchPage', () => {
};
});
it('if there is a searchTerm ', () => {
const { props, wrapper } = setup({ searchTerm: 'data' });
const { wrapper } = setup({ searchTerm: 'data' });
content = shallow(
wrapper.instance().getTabContent(testResults, ResourceType.table)
);
......@@ -180,7 +175,7 @@ describe('SearchPage', () => {
});
it('if no searchTerm but there are filters active', () => {
const { props, wrapper } = setup({ searchTerm: '', hasFilters: true });
const { wrapper } = setup({ searchTerm: '', hasFilters: true });
content = shallow(
wrapper.instance().getTabContent(testResults, ResourceType.table)
);
......@@ -191,7 +186,7 @@ describe('SearchPage', () => {
describe('if page index is out of bounds', () => {
it('renders expected page index error message', () => {
const { props, wrapper } = setup();
const { wrapper } = setup();
const testResults = {
page_index: 2,
results: [],
......@@ -210,6 +205,7 @@ describe('SearchPage', () => {
let props;
let wrapper;
let generateInfoTextMockResults;
beforeAll(() => {
const setupResult = setup({ searchTerm: '' });
props = setupResult.props;
......@@ -246,15 +242,19 @@ describe('SearchPage', () => {
describe('renderContent', () => {
it('renders search results when given search term', () => {
const { props, wrapper } = setup({ searchTerm: 'test' });
const { wrapper } = setup({ searchTerm: 'test' });
expect(wrapper.instance().renderContent()).toEqual(
wrapper.instance().renderSearchResults()
);
});
it('renders loading spinner when in loading state', () => {
const { props, wrapper } = setup({ isLoading: true });
expect(wrapper.instance().renderContent()).toEqual(<LoadingSpinner />);
it('renders shimmering loader when in loading state', () => {
const { wrapper } = setup({ isLoading: true });
const expected = 1;
const actual = wrapper.find('ShimmeringResourceLoader').length;
expect(actual).toEqual(expected);
});
});
......@@ -296,7 +296,7 @@ describe('SearchPage', () => {
});
it('renders null for an invalid resource', () => {
const { props, wrapper } = setup({
const { wrapper } = setup({
resource: null,
});
const renderedSearchResults = wrapper.instance().renderSearchResults();
......@@ -307,14 +307,14 @@ describe('SearchPage', () => {
describe('render', () => {
describe('DocumentTitle', () => {
it('renders correct title if there is a search term', () => {
const { props, wrapper } = setup({ searchTerm: 'test search' });
const { wrapper } = setup({ searchTerm: 'test search' });
expect(wrapper.find(DocumentTitle).props()).toMatchObject({
title: `test search${DOCUMENT_TITLE_SUFFIX}`,
});
});
it('does not render DocumentTitle if searchTerm is empty string', () => {
const { props, wrapper } = setup({ searchTerm: '' });
const { wrapper } = setup({ searchTerm: '' });
expect(wrapper.find(DocumentTitle).exists()).toBeFalsy();
});
});
......@@ -331,13 +331,11 @@ describe('SearchPage', () => {
});
describe('renders a SearchPanel', () => {
let props;
let wrapper;
let searchPanel;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
({ wrapper } = setup());
searchPanel = wrapper.find(SearchPanel);
});
it('renders a search panel', () => {
......
......@@ -7,6 +7,7 @@ import { Search as UrlSearch } from 'history';
import LoadingSpinner from 'components/common/LoadingSpinner';
import PaginatedApiResourceList from 'components/common/ResourceList/PaginatedApiResourceList';
import ShimmeringResourceLoader from 'components/common/ShimmeringResourceLoader';
import { GlobalState } from 'ducks/rootReducer';
import { submitSearchResource, urlDidUpdate } from 'ducks/search/reducer';
......@@ -157,8 +158,9 @@ export class SearchPage extends React.Component<SearchPageProps> {
renderContent = () => {
if (this.props.isLoading) {
return <LoadingSpinner />;
return <ShimmeringResourceLoader numItems={RESULTS_PER_PAGE} />;
}
return this.renderSearchResults();
};
......
......@@ -152,7 +152,6 @@ describe('MyBookmarks', () => {
describe('generateTabInfo', () => {
let tabInfoArray;
let props;
let wrapper;
let generateTabContentSpy;
let generateTabKeySpy;
......@@ -160,8 +159,8 @@ describe('MyBookmarks', () => {
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
generateTabContentSpy = jest
.spyOn(wrapper.instance(), 'generateTabContent')
.mockImplementation((input) => `${input}Content`);
......@@ -240,17 +239,20 @@ describe('MyBookmarks', () => {
});
describe('render', () => {
let props;
let wrapper;
beforeAll(() => {
const setupResult = setup();
props = setupResult.props;
wrapper = setupResult.wrapper;
});
it('renders nothing until ready', () => {
const { props, wrapper } = setup({ isLoaded: false });
expect(wrapper.html()).toBeFalsy();
it('renders a shimmer loader until ready', () => {
const { wrapper } = setup({ isLoaded: false });
const expected = 1;
const actual = wrapper.find('ShimmeringResourceLoader').length;
expect(actual).toEqual(expected);
});
it('renders the correct title', () => {
......@@ -258,10 +260,12 @@ describe('MyBookmarks', () => {
});
it('renders a TabsComponent with correct props', () => {
const generateTabKeySpy = jest
jest
.spyOn(wrapper.instance(), 'generateTabKey')
.mockImplementation((input) => `${input}Key`);
wrapper.instance().forceUpdate();
expect(wrapper.find(TabsComponent).props()).toMatchObject({
tabs: wrapper.instance().generateTabInfo(),
defaultTab: 'tableKey',
......
......@@ -10,6 +10,7 @@ import {
} from 'config/config-utils';
import PaginatedResourceList from 'components/common/ResourceList/PaginatedResourceList';
import TabsComponent from 'components/common/TabsComponent';
import ShimmeringResourceLoader from 'components/common/ShimmeringResourceLoader';
import {
BOOKMARK_TITLE,
BOOKMARKS_PER_PAGE,
......@@ -27,9 +28,11 @@ export type MyBookmarksProps = StateFromProps;
export class MyBookmarks extends React.Component<MyBookmarksProps> {
generateTabContent = (resource: ResourceType) => {
const bookmarks = this.props.myBookmarks[resource];
if (!bookmarks) {
return null;
}
return (
<PaginatedResourceList
allItems={bookmarks}
......@@ -46,9 +49,11 @@ export class MyBookmarks extends React.Component<MyBookmarksProps> {
generateTabTitle = (resource: ResourceType): string => {
const bookmarks = this.props.myBookmarks[resource];
if (!bookmarks) {
return '';
}
return `${getDisplayNameByResource(resource)} (${bookmarks.length})`;
};
......@@ -73,17 +78,21 @@ export class MyBookmarks extends React.Component<MyBookmarksProps> {
};
render() {
if (!this.props.isLoaded) {
return null;
}
let content = <ShimmeringResourceLoader numItems={BOOKMARKS_PER_PAGE} />;
return (
<div className="bookmark-list">
<div className="title-1">{BOOKMARK_TITLE}</div>
if (this.props.isLoaded) {
content = (
<TabsComponent
tabs={this.generateTabInfo()}
defaultTab={this.generateTabKey(ResourceType.table)}
/>
);
}
return (
<div className="bookmark-list">
<div className="title-1">{BOOKMARK_TITLE}</div>
{content}
</div>
);
}
......
......@@ -17,9 +17,9 @@ import {
mapDispatchToProps,
} from '.';
describe('PopularTables', () => {
const setup = (propOverrides?: Partial<PopularTablesProps>) => {
const setup = (propOverrides?: Partial<PopularTablesProps>) => {
const props: PopularTablesProps = {
isLoaded: false,
popularTables: jest.fn() as any,
getPopularTables: jest.fn(),
...propOverrides,
......@@ -27,16 +27,18 @@ describe('PopularTables', () => {
const wrapper = shallow<PopularTables>(<PopularTables {...props} />);
return { props, wrapper };
};
};
describe('PopularTables', () => {
let wrapper;
let props;
describe('componentDidMount', () => {
let getPopularTablesSpy;
beforeAll(() => {
const setupResult = setup();
wrapper = setupResult.wrapper;
props = setupResult.props;
({ wrapper, props } = setup());
getPopularTablesSpy = jest.spyOn(props, 'getPopularTables');
});
......@@ -46,14 +48,11 @@ describe('PopularTables', () => {
});
describe('mapStateToProps', () => {
let result;
beforeAll(() => {
result = mapStateToProps(globalState);
});
it('sets popularTables on the props', () => {
expect(result.popularTables).toEqual(globalState.popularTables);
const actual = mapStateToProps(globalState).popularTables;
const expected = globalState.popularTables.popularTables;
expect(actual).toEqual(expected);
});
});
......@@ -72,14 +71,14 @@ describe('PopularTables', () => {
describe('render', () => {
beforeAll(() => {
const setupResult = setup();
wrapper = setupResult.wrapper;
props = setupResult.props;
({ wrapper, props } = setup());
});
it('renders correct label for content', () => {
expect(wrapper.children().find('label').text()).toEqual(
POPULAR_TABLES_LABEL
);
const actual = wrapper.children().find('label').text();
const expected = POPULAR_TABLES_LABEL;
expect(actual).toEqual(expected);
});
it('renders InfoButton with correct props', () => {
......@@ -88,13 +87,32 @@ describe('PopularTables', () => {
});
});
describe('when loading', () => {
it('renders loading state', () => {
const actual = wrapper.find('ShimmeringResourceLoader').length;
const expected = 1;
expect(actual).toEqual(expected);
});
});
describe('when loaded', () => {
beforeAll(() => {
({ wrapper, props } = setup({
isLoaded: true,
popularTables: globalState.popularTables.popularTables,
}));
});
it('renders PaginatedResourceList with correct props', () => {
expect(
wrapper.children().find(PaginatedResourceList).props()
).toMatchObject({
const actual = wrapper.children().find(PaginatedResourceList).props();
const expected = {
allItems: props.popularTables,
itemsPerPage: POPULAR_TABLES_PER_PAGE,
source: POPULAR_TABLES_SOURCE_NAME,
};
expect(actual).toMatchObject(expected);
});
});
});
......
import * as React from 'react';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
import { TableResource } from 'interfaces';
import InfoButton from 'components/common/InfoButton';
import PaginatedResourceList from 'components/common/ResourceList/PaginatedResourceList';
import ShimmeringResourceLoader from 'components/common/ShimmeringResourceLoader';
import { getPopularTables } from 'ducks/popularTables/reducer';
import { GetPopularTablesRequest } from 'ducks/popularTables/types';
......@@ -20,8 +18,11 @@ import {
POPULAR_TABLES_PER_PAGE,
} from './constants';
import './styles.scss';
export interface StateFromProps {
popularTables: TableResource[];
isLoaded: boolean;
}
export interface DispatchFromProps {
......@@ -36,24 +37,36 @@ export class PopularTables extends React.Component<PopularTablesProps> {
}
render() {
const { popularTables, isLoaded } = this.props;
let content = (
<ShimmeringResourceLoader numItems={POPULAR_TABLES_PER_PAGE} />
);
if (isLoaded) {
content = (
<PaginatedResourceList
allItems={popularTables}
itemsPerPage={POPULAR_TABLES_PER_PAGE}
source={POPULAR_TABLES_SOURCE_NAME}
/>
);
}
return (
<>
<div className="popular-tables-header">
<label className="title-1">{POPULAR_TABLES_LABEL}</label>
<InfoButton infoText={POPULAR_TABLES_INFO_TEXT} />
</div>
<PaginatedResourceList
allItems={this.props.popularTables}
itemsPerPage={POPULAR_TABLES_PER_PAGE}
source={POPULAR_TABLES_SOURCE_NAME}
/>
{content}
</>
);
}
}
export const mapStateToProps = (state: GlobalState) => {
return {
popularTables: state.popularTables,
popularTables: state.popularTables.popularTables,
isLoaded: state.popularTables.popularTablesIsLoaded,
};
};
......
......@@ -26,6 +26,7 @@ class PaginatedApiResourceList extends React.Component<
onPagination = (rawPageNum: number) => {
const activePage = rawPageNum - 1;
this.props.onPagination(activePage);
};
......
import * as React from 'react';
import { mount } from 'enzyme';
import ShimmeringResourceLoader, {
ShimmeringResourceItem,
ShimmeringResourceLoaderProps,
} from '.';
const setup = (propOverrides?: Partial<ShimmeringResourceLoaderProps>) => {
const props: ShimmeringResourceLoaderProps = {
...propOverrides,
};
const wrapper = mount<ShimmeringResourceLoaderProps>(
<ShimmeringResourceLoader {...props} />
);
return { props, wrapper };
};
describe('ShimmeringResourceLoader', () => {
let wrapper;
describe('render', () => {
beforeAll(() => {
({ wrapper } = setup());
});
it('renders a container', () => {
const actual = wrapper.find('.shimmer-resource-loader').length;
const expected = 1;
expect(actual).toEqual(expected);
});
it('renders three items by default', () => {
const actual = wrapper.find(ShimmeringResourceItem).length;
const expected = 3;
expect(actual).toEqual(expected);
});
describe('when passing a numItems value', () => {
it('renders as many items as requested', () => {
const expected = 5;
({ wrapper } = setup({ numItems: expected }));
const actual = wrapper.find(ShimmeringResourceItem).length;
expect(actual).toEqual(expected);
});
});
});
});
import * as React from 'react';
import * as times from 'lodash/times';
import './styles.scss';
const DEFAULT_REPETITION = 3;
export const ShimmeringResourceItem: React.SFC = () => {
return (
<div className="shimmer-resource-loader-item media">
<div className="media-left media-middle">
<div className="shimmer-resource-circle is-shimmer-animated" />
</div>
<div className="media-body">
<div className="shimmer-resource-line shimmer-resource-line--1 is-shimmer-animated" />
<div className="shimmer-resource-line shimmer-resource-line--2 is-shimmer-animated" />
</div>
</div>
);
};
export interface ShimmeringResourceLoaderProps {
numItems?: number;
}
const ShimmeringResourceLoader: React.SFC<ShimmeringResourceLoaderProps> = ({
numItems = DEFAULT_REPETITION,
}: ShimmeringResourceLoaderProps) => {
return (
<div className="shimmer-resource-loader">
{times(numItems, (idx) => (
<ShimmeringResourceItem key={idx} />
))}
</div>
);
};
export default ShimmeringResourceLoader;
@import 'variables';
$shimmer-loader-lines: 1, 2;
$shimmer-loader-text-height: 16px;
$shimmer-loader-circle-size: 24px;
$shimmer-loader-border-size: 1px;
.shimmer-resource-loader-item {
padding: $spacer-3;
border-top: $shimmer-loader-border-size solid $stroke;
border-bottom: $shimmer-loader-border-size solid $stroke;
margin-bottom: -$shimmer-loader-border-size;
margin-top: 0;
& .media-left {
padding-right: $spacer-2;
}
}
.shimmer-resource-circle {
height: $shimmer-loader-circle-size;
width: $shimmer-loader-circle-size;
border-radius: $shimmer-loader-circle-size;
}
.shimmer-resource-line {
margin-bottom: $spacer-2;
height: $shimmer-loader-text-height;
&:last-child {
margin-bottom: 0;
}
}
@each $line in $shimmer-loader-lines {
.shimmer-resource-line--#{$line} {
width: percentage(random(100) / 100);
}
}
......@@ -9,11 +9,11 @@ import * as API from '../v0';
jest.mock('axios');
describe('getPopularTables', () => {
let axiosMock;
let expectedTables: TableResource[];
let mockGetResponse: AxiosResponse<API.PopularTablesAPI>;
beforeAll(() => {
expectedTables = globalState.popularTables;
expectedTables = globalState.popularTables.popularTables;
mockGetResponse = {
data: {
results: expectedTables,
......@@ -24,7 +24,7 @@ describe('getPopularTables', () => {
headers: {},
config: {},
};
axiosMock = jest
jest
.spyOn(axios, 'get')
.mockImplementation(() => Promise.resolve(mockGetResponse));
});
......
......@@ -4,25 +4,23 @@ import { TableResource } from 'interfaces';
import globalState from 'fixtures/globalState';
import * as API from '../api/v0';
import * as API from './api/v0';
import reducer, {
getPopularTables,
getPopularTablesFailure,
getPopularTablesSuccess,
PopularTablesReducerState,
} from '../reducer';
import { getPopularTablesWorker, getPopularTablesWatcher } from '../sagas';
import {
GetPopularTables,
GetPopularTablesRequest,
GetPopularTablesResponse,
} from '../types';
} from './reducer';
import { getPopularTablesWorker, getPopularTablesWatcher } from './sagas';
import { GetPopularTables } from './types';
describe('popularTables ducks', () => {
let expectedTables: TableResource[];
beforeAll(() => {
expectedTables = globalState.popularTables;
expectedTables = globalState.popularTables.popularTables;
});
describe('actions', () => {
it('getPopularTables - returns the action to get popular tables', () => {
const action = getPopularTables();
......@@ -46,21 +44,34 @@ describe('popularTables ducks', () => {
describe('reducer', () => {
let testState: PopularTablesReducerState;
beforeAll(() => {
testState = [];
testState = {
popularTablesIsLoaded: false,
popularTables: [],
};
});
it('should return the existing state if action is not handled', () => {
expect(reducer(testState, { type: 'INVALID.ACTION' })).toEqual(testState);
});
it('should handle GetPopularTables.SUCCESS', () => {
expect(
reducer(testState, getPopularTablesSuccess(expectedTables))
).toEqual(expectedTables);
const expected = expectedTables;
const actual = reducer(testState, getPopularTablesSuccess(expectedTables))
.popularTables;
expect(actual).toEqual(expected);
});
it('should handle GetPopularTables.FAILURE', () => {
expect(reducer(testState, getPopularTablesFailure())).toEqual([]);
const expected = {
popularTables: [],
popularTablesIsLoaded: true,
};
const actual = reducer(testState, getPopularTablesFailure());
expect(actual).toEqual(expected);
});
});
......
......@@ -20,18 +20,33 @@ export function getPopularTablesSuccess(
}
/* REDUCER */
export type PopularTablesReducerState = TableResource[];
export interface PopularTablesReducerState {
popularTables: TableResource[];
popularTablesIsLoaded: boolean;
}
const initialState: PopularTablesReducerState = [];
const initialState: PopularTablesReducerState = {
popularTables: [],
popularTablesIsLoaded: false,
};
export default function reducer(
state: PopularTablesReducerState = initialState,
action
): PopularTablesReducerState {
switch (action.type) {
case GetPopularTables.REQUEST:
return {
...state,
...initialState,
};
case GetPopularTables.SUCCESS:
case GetPopularTables.FAILURE:
return (<GetPopularTablesResponse>action).payload.tables;
return {
...state,
popularTables: (<GetPopularTablesResponse>action).payload.tables,
popularTablesIsLoaded: true,
};
default:
return state;
}
......
......@@ -73,6 +73,8 @@ const globalState: GlobalState = {
requestIsOpen: false,
sendState: SendingState.IDLE,
},
popularTables: {
popularTablesIsLoaded: true,
popularTables: [
{
cluster: 'testCluster',
......@@ -93,6 +95,7 @@ const globalState: GlobalState = {
type: ResourceType.table,
},
],
},
search: {
search_term: 'testName',
resource: ResourceType.table,
......
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