Unverified Commit 1c286309 authored by Daniel's avatar Daniel Committed by GitHub

Added `selectedTab` to global search state (#214)

Breadcrumbs will return to the correct search tab and page index

- Added selectedTab to global search state, removed selectedTab from SearchPage state.
- Simplified SearchPage logic. Every action first updates the page URL, which is then read by componentDidUpdate and compared to the current state to determine if an action needs to be taken.
- Split SearchResponsePayload into SearchResponsePayload and searchAllResponsepayload
- Added jest mock functions to mock router history functions.
- Clicking breadcrumb does not re-execute a search
- Fixes redirect issue creating multiple window.history entries.
parent c7607a91
......@@ -4,6 +4,7 @@ import { bindActionCreators } from 'redux';
import * as DocumentTitle from 'react-document-title';
import * as qs from 'simple-query-string';
import { RouteComponentProps } from 'react-router';
import { Search } from 'history';
import AppConfig from 'config/config';
import LoadingSpinner from 'components/common/LoadingSpinner';
......@@ -13,17 +14,18 @@ import TabsComponent from 'components/common/Tabs';
import SearchBar from './SearchBar';
import { GlobalState } from 'ducks/rootReducer';
import { searchAll, searchResource } from 'ducks/search/reducer';
import { searchAll, searchResource, updateSearchTab } from 'ducks/search/reducer';
import {
DashboardSearchResults,
SearchAllRequest,
SearchResourceRequest,
SearchResults,
TableSearchResults,
UpdateSearchTabRequest,
UserSearchResults,
} from 'ducks/search/types';
import { Resource, ResourceType, SearchAllOptions } from 'interfaces';
import { Resource, ResourceType } from 'interfaces';
// TODO: Use css-modules instead of 'import'
import './styles.scss';
......@@ -44,6 +46,7 @@ import {
export interface StateFromProps {
searchTerm: string;
selectedTab: ResourceType;
isLoading: boolean;
tables: TableSearchResults;
dashboards: DashboardSearchResults;
......@@ -51,51 +54,78 @@ export interface StateFromProps {
}
export interface DispatchFromProps {
searchAll: (term: string, options?: SearchAllOptions) => SearchAllRequest;
searchResource: (resource: ResourceType, term: string, pageIndex: number) => SearchResourceRequest;
searchAll: (term: string, selectedTab: ResourceType, pageIndex: number) => SearchAllRequest;
searchResource: (term: string, resource: ResourceType, pageIndex: number) => SearchResourceRequest;
updateSearchTab: (selectedTab: ResourceType) => UpdateSearchTabRequest;
}
export type SearchPageProps = StateFromProps & DispatchFromProps & RouteComponentProps<any>;
interface SearchPageState {
selectedTab: ResourceType;
}
export class SearchPage extends React.Component<SearchPageProps, SearchPageState> {
export class SearchPage extends React.Component<SearchPageProps> {
public static defaultProps: Partial<SearchPageProps> = {};
constructor(props) {
super(props);
this.state = {
selectedTab: ResourceType.table,
};
}
componentDidMount() {
const params = qs.parse(this.props.location.search);
const { searchTerm, pageIndex, selectedTab } = params;
const { term, index, currentTab } = this.getSanitizedUrlParams(searchTerm, pageIndex, selectedTab);
this.setState({ selectedTab: currentTab });
if (term !== '') {
this.props.searchAll(term, this.createSearchOptions(index, currentTab));
if (currentTab !== selectedTab || pageIndex !== index) {
this.updatePageUrl(term, currentTab, index);
const urlSearchParams = this.getUrlParams(this.props.location.search);
const globalStateParams = this.getGlobalStateParams();
if (this.shouldUpdateFromGlobalState(urlSearchParams, globalStateParams)) {
this.updatePageUrl(globalStateParams.term, globalStateParams.tab, globalStateParams.index, true);
} else if (this.shouldUpdateFromUrlParams(urlSearchParams, globalStateParams)) {
this.props.searchAll(urlSearchParams.term, urlSearchParams.tab, urlSearchParams.index);
this.updatePageUrl(urlSearchParams.term, urlSearchParams.tab, urlSearchParams.index, true);
}
}
shouldUpdateFromGlobalState(urlParams, globalStateParams): boolean {
return urlParams.term === '' && globalStateParams.term !== '';
}
shouldUpdateFromUrlParams(urlParams, globalStateParams): boolean {
return urlParams.term !== '' && urlParams.term !== globalStateParams.term;
}
componentDidUpdate(prevProps: SearchPageProps) {
const nextUrlParams = this.getUrlParams(this.props.location.search);
const prevUrlParams = this.getUrlParams(prevProps.location.search);
// If urlParams and globalState are synced, no need to update
if (this.isUrlStateSynced(nextUrlParams)) return;
// Capture any updates in URL
if (this.shouldUpdateSearchTerm(nextUrlParams, prevUrlParams)) {
this.props.searchAll(nextUrlParams.term, nextUrlParams.tab, nextUrlParams.index);
} else if (this.shouldUpdateTab(nextUrlParams, prevUrlParams)) {
this.props.updateSearchTab(nextUrlParams.tab)
} else if (this.shouldUpdatePageIndex(nextUrlParams, prevUrlParams)) {
this.props.searchResource(nextUrlParams.term, nextUrlParams.tab, nextUrlParams.index);
}
}
isUrlStateSynced(urlParams): boolean {
const globalStateParams = this.getGlobalStateParams();
return urlParams.term === globalStateParams.term &&
urlParams.tab === globalStateParams.tab &&
urlParams.index === globalStateParams.index;
}
componentDidUpdate(prevProps) {
if (this.props.location.search !== prevProps.location.search) {
const params = qs.parse(this.props.location.search);
const { searchTerm, pageIndex, selectedTab } = params;
const { term, index, currentTab } = this.getSanitizedUrlParams(searchTerm, pageIndex, selectedTab);
this.setState({ selectedTab: currentTab });
const prevTerm = prevProps.searchTerm;
if (term !== prevTerm) {
this.props.searchAll(term, this.createSearchOptions(index, currentTab));
shouldUpdateSearchTerm(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.term !== prevUrlParams.term;
}
shouldUpdateTab(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.tab !== prevUrlParams.tab;
}
shouldUpdatePageIndex(nextUrlParams, prevUrlParams): boolean {
return nextUrlParams.index !== prevUrlParams.index;
}
getSelectedTabByResourceType = (newTab: ResourceType): ResourceType => {
......@@ -105,24 +135,29 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
return newTab;
case ResourceType.dashboard:
default:
return this.state.selectedTab;
return this.props.selectedTab;
}
};
createSearchOptions = (pageIndex: number, selectedTab: ResourceType) => {
getUrlParams(search: Search) {
const urlParams = qs.parse(search);
const { searchTerm, pageIndex, selectedTab } = urlParams;
const index = parseInt(pageIndex, 10);
return {
dashboardIndex: (selectedTab === ResourceType.dashboard) ? pageIndex : 0,
userIndex: (selectedTab === ResourceType.user) ? pageIndex : 0,
tableIndex: (selectedTab === ResourceType.table) ? pageIndex : 0,
term: (searchTerm || '').trim(),
tab: this.getSelectedTabByResourceType(selectedTab),
index: isNaN(index) ? 0 : index,
};
};
getSanitizedUrlParams = (searchTerm: string, pageIndex: number, selectedTab: ResourceType) => {
const currentTab = this.getSelectedTabByResourceType(selectedTab);
const index = pageIndex || 0;
const term = searchTerm ? searchTerm : "";
return {term, index, currentTab};
getGlobalStateParams() {
return {
term: this.props.searchTerm,
tab: this.props.selectedTab,
index: this.getPageIndexByResourceType(this.props.selectedTab),
};
}
getPageIndexByResourceType = (tab: ResourceType): number => {
switch(tab) {
......@@ -137,19 +172,22 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
};
onPaginationChange = (index: number): void => {
this.props.searchResource(this.state.selectedTab, this.props.searchTerm, index);
this.updatePageUrl(this.props.searchTerm, this.state.selectedTab, index);
this.updatePageUrl(this.props.searchTerm, this.props.selectedTab, index);
};
onTabChange = (tab: ResourceType): void => {
const currentTab = this.getSelectedTabByResourceType(tab);
this.setState({ selectedTab: currentTab });
this.updatePageUrl(this.props.searchTerm, currentTab, this.getPageIndexByResourceType(currentTab));
const newTab = this.getSelectedTabByResourceType(tab);
this.updatePageUrl(this.props.searchTerm, newTab, this.getPageIndexByResourceType(newTab));
};
updatePageUrl = (searchTerm: string, tab: ResourceType, pageIndex: number): void => {
updatePageUrl = (searchTerm: string, tab: ResourceType, pageIndex: number, replace: boolean = false): void => {
const pathName = `/search?searchTerm=${searchTerm}&selectedTab=${tab}&pageIndex=${pageIndex}`;
if (replace) {
this.props.history.replace(pathName);
} else {
this.props.history.push(pathName);
}
};
renderSearchResults = () => {
......@@ -173,7 +211,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
<TabsComponent
tabs={ tabConfig }
defaultTab={ ResourceType.table }
activeKey={ this.state.selectedTab }
activeKey={ this.props.selectedTab }
onSelect={ this.onTabChange }
/>
</div>
......@@ -283,6 +321,7 @@ export class SearchPage extends React.Component<SearchPageProps, SearchPageState
export const mapStateToProps = (state: GlobalState) => {
return {
searchTerm: state.search.search_term,
selectedTab: state.search.selectedTab,
isLoading: state.search.isLoading,
tables: state.search.tables,
users: state.search.users,
......@@ -291,7 +330,7 @@ export const mapStateToProps = (state: GlobalState) => {
};
export const mapDispatchToProps = (dispatch: any) => {
return bindActionCreators({ searchAll, searchResource } , dispatch);
return bindActionCreators({ searchAll, searchResource, updateSearchTab }, dispatch);
};
export default connect<StateFromProps, DispatchFromProps>(mapStateToProps, mapDispatchToProps)(SearchPage);
......@@ -23,7 +23,7 @@ export const Breadcrumb: React.SFC<BreadcrumbProps> = (props) => {
path = '/';
text = 'Home';
if (props.searchTerm) {
path = `/search?searchTerm=${props.searchTerm}&selectedTab=table&pageIndex=0`;
path = `/search`;
text = 'Search Results';
}
}
......
......@@ -40,7 +40,7 @@ describe('Breadcrumb', () => {
it('renders Link with correct path', () => {
expect(subject.find(Link).props()).toMatchObject({
to: '/search?searchTerm=testTerm&selectedTab=table&pageIndex=0',
to: '/search',
});
});
......
......@@ -6,7 +6,7 @@ import { DashboardSearchResults, TableSearchResults, UserSearchResults } from 'd
import globalState from 'fixtures/globalState';
import { ResourceType, SearchAllOptions } from 'interfaces';
import { ResourceType } from 'interfaces';
import * as API from '../v0';
......
import { ResourceType, SearchAllOptions } from 'interfaces';
import { ResourceType } from 'interfaces';
import {
SearchResponsePayload,
DashboardSearchResults,
SearchAll,
SearchAllRequest,
SearchAllReset,
SearchAllResponse,
SearchAllResponsePayload,
SearchResource,
SearchResponsePayload,
SearchResourceRequest,
SearchResourceResponse,
DashboardSearchResults,
TableSearchResults,
UpdateSearchTab,
UpdateSearchTabRequest,
UserSearchResults,
} from './types';
export interface SearchReducerState {
search_term: string;
selectedTab: ResourceType;
isLoading: boolean;
dashboards: DashboardSearchResults;
tables: TableSearchResults;
......@@ -23,23 +27,24 @@ export interface SearchReducerState {
};
/* ACTIONS */
export function searchAll(term: string, options: SearchAllOptions): SearchAllRequest {
export function searchAll(term: string, resource: ResourceType, pageIndex: number): SearchAllRequest {
return {
payload: {
options,
resource,
pageIndex,
term,
},
type: SearchAll.REQUEST,
};
};
export function searchAllSuccess(searchResults: SearchResponsePayload): SearchAllResponse {
export function searchAllSuccess(searchResults: SearchAllResponsePayload): SearchAllResponse {
return { type: SearchAll.SUCCESS, payload: searchResults };
};
export function searchAllFailure(): SearchAllResponse {
return { type: SearchAll.FAILURE };
};
export function searchResource(resource: ResourceType, term: string, pageIndex: number): SearchResourceRequest {
export function searchResource(term: string, resource: ResourceType, pageIndex: number): SearchResourceRequest {
return {
payload: {
pageIndex,
......@@ -62,10 +67,19 @@ export function searchReset(): SearchAllReset {
};
};
export function updateSearchTab(selectedTab: ResourceType): UpdateSearchTabRequest {
return {
payload: { selectedTab },
type: UpdateSearchTab.REQUEST,
};
}
/* REDUCER */
export const initialState: SearchReducerState = {
search_term: '',
isLoading: false,
selectedTab: ResourceType.table,
dashboards: {
page_index: 0,
results: [],
......@@ -121,6 +135,11 @@ export default function reducer(state: SearchReducerState = initialState, action
...initialState,
search_term: state.search_term,
};
case UpdateSearchTab.REQUEST:
return {
...state,
selectedTab: (<UpdateSearchTabRequest>action).payload.selectedTab
};
default:
return state;
};
......
......@@ -13,25 +13,30 @@ import {
} from './types';
import {
searchAllSuccess, searchAllFailure,
initialState, searchAllSuccess, searchAllFailure,
searchResourceSuccess, searchResourceFailure,
} from './reducer';
export function* searchAllWorker(action: SearchAllRequest): SagaIterator {
const { options, term } = action.payload;
const { resource, pageIndex, term } = action.payload;
const tableIndex = resource === ResourceType.table ? pageIndex : 0;
const userIndex = resource === ResourceType.user ? pageIndex : 0;
const dashboardIndex = resource === ResourceType.dashboard ? pageIndex : 0;
try {
const [tableResponse, userResponse, dashboardResponse] = yield all([
call(API.searchResource, options.tableIndex, ResourceType.table, term),
call(API.searchResource, options.userIndex, ResourceType.user, term),
call(API.searchResource, options.dashboardIndex, ResourceType.dashboard, term),
call(API.searchResource, tableIndex, ResourceType.table, term),
call(API.searchResource, userIndex, ResourceType.user, term),
call(API.searchResource, dashboardIndex, ResourceType.dashboard, term),
]);
const searchAllResponse = {
search_term: term,
tables: tableResponse.tables,
users: userResponse.users,
dashboards: dashboardResponse.dashboards,
selectedTab: resource,
tables: tableResponse.tables || initialState.tables,
users: userResponse.users || initialState.users,
dashboards: dashboardResponse.dashboards || initialState.dashboards,
};
yield put({ type: SearchAll.SUCCESS, payload: searchAllResponse });
yield put(searchAllSuccess(searchAllResponse));
} catch (e) {
yield put(searchAllFailure());
}
......
......@@ -2,48 +2,92 @@ import { testSaga } from 'redux-saga-test-plan';
import { ResourceType } from 'interfaces';
import globalState from 'fixtures/globalState';
import * as API from '../api/v0';
import reducer, {
searchAll, searchAllSuccess, searchAllFailure,
searchResource, searchResourceSuccess, searchResourceFailure,
initialState,
searchAll,
searchAllFailure,
searchAllSuccess,
SearchReducerState,
searchReset,
initialState, SearchReducerState,
searchResource,
searchResourceFailure,
searchResourceSuccess,
updateSearchTab,
} from '../reducer';
import {
searchAllWatcher, searchAllWorker,
searchResourceWatcher, searchResourceWorker
} from '../sagas';
import {
SearchAll, SearchAllRequest, SearchAllResponse,
SearchResource, SearchResourceRequest, SearchResourceResponse,
SearchResponsePayload,
} from '../types';
import { searchAllWatcher, searchAllWorker, searchResourceWatcher, searchResourceWorker } from '../sagas';
import { SearchAll, SearchAllResponsePayload, SearchResource, SearchResponsePayload, UpdateSearchTab, } from '../types';
describe('search ducks', () => {
let expectedSearchResults: SearchResponsePayload;
beforeAll(() => {
expectedSearchResults = globalState.search;
});
const expectedSearchResults: SearchResponsePayload = {
search_term: 'testName',
tables: {
page_index: 0,
results: [
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName',
last_updated_epoch: 946684799,
name: 'testName',
schema_name: 'testSchema',
type: ResourceType.table,
},
],
total_results: 1,
},
};
const expectedSearchAllResults: SearchAllResponsePayload = {
search_term: 'testName',
selectedTab: ResourceType.table,
dashboards: {
page_index: 0,
results: [],
total_results: 0,
},
tables: {
page_index: 0,
results: [
{
cluster: 'testCluster',
database: 'testDatabase',
description: 'I have a lot of users',
key: 'testDatabase://testCluster.testSchema/testName',
last_updated_epoch: 946684799,
name: 'testName',
schema_name: 'testSchema',
type: ResourceType.table,
},
],
total_results: 1,
},
users: {
page_index: 0,
results: [],
total_results: 0,
},
};
describe('actions', () => {
it('searchAll - returns the action to search all resources', () => {
const term = 'test';
const options = {};
const action = searchAll(term, options);
const resource = ResourceType.table;
const pageIndex = 0;
const action = searchAll(term, resource, pageIndex);
const { payload } = action;
expect(action.type).toBe(SearchAll.REQUEST);
expect(payload.options).toBe(options);
expect(payload.resource).toBe(resource);
expect(payload.term).toBe(term);
expect(payload.pageIndex).toBe(pageIndex);
});
it('searchAllSuccess - returns the action to process the success', () => {
const action = searchAllSuccess(expectedSearchResults);
const action = searchAllSuccess(expectedSearchAllResults);
const { payload } = action;
expect(action.type).toBe(SearchAll.SUCCESS);
expect(payload).toBe(expectedSearchResults);
expect(payload).toBe(expectedSearchAllResults);
});
it('searchAllFailure - returns the action to process the failure', () => {
......@@ -52,10 +96,10 @@ describe('search ducks', () => {
});
it('searchResource - returns the action to search all resources', () => {
const pageIndex = 0;
const resource = ResourceType.table;
const term = 'test';
const action = searchResource(resource, term, pageIndex);
const resource = ResourceType.table;
const pageIndex = 0;
const action = searchResource(term, resource, pageIndex);
const { payload } = action;
expect(action.type).toBe(SearchResource.REQUEST);
expect(payload.resource).toBe(resource);
......@@ -79,6 +123,14 @@ describe('search ducks', () => {
const action = searchReset();
expect(action.type).toBe(SearchAll.RESET);
});
it('updateSearchTab - returns the action to update the search tab', () => {
const selectedTab = ResourceType.user;
const action = updateSearchTab(selectedTab);
const payload = action.payload;
expect(action.type).toBe(UpdateSearchTab.REQUEST);
expect(payload.selectedTab).toBe(selectedTab);
});
});
describe('reducer', () => {
......@@ -92,8 +144,9 @@ describe('search ducks', () => {
it('should handle SearchAll.REQUEST', () => {
const term = 'testSearch';
const options = {};
expect(reducer(testState, searchAll(term, options))).toEqual({
const resource = ResourceType.table;
const pageIndex = 0;
expect(reducer(testState, searchAll(term, resource, pageIndex))).toEqual({
...testState,
search_term: term,
isLoading: true,
......@@ -101,7 +154,7 @@ describe('search ducks', () => {
});
it('should handle SearchAll.SUCCESS', () => {
expect(reducer(testState, searchAllSuccess(expectedSearchResults))).toEqual({
expect(reducer(testState, searchAllSuccess(expectedSearchAllResults))).toEqual({
...initialState,
...expectedSearchResults,
isLoading: false,
......@@ -120,7 +173,7 @@ describe('search ducks', () => {
});
it('should handle SearchResource.REQUEST', () => {
expect(reducer(testState, searchResource(ResourceType.table, 'test', 0))).toEqual({
expect(reducer(testState, searchResource('test', ResourceType.table, 0))).toEqual({
...initialState,
isLoading: true,
});
......@@ -140,6 +193,14 @@ describe('search ducks', () => {
search_term: testState.search_term,
});
});
it('should handle UpdateSearchTab.REQUEST', () => {
const selectedTab = ResourceType.user;
expect(reducer(testState, updateSearchTab(selectedTab))).toEqual({
...testState,
selectedTab,
});
});
});
describe('sagas', () => {
......@@ -166,7 +227,7 @@ describe('search ducks', () => {
});*/
it('handles request error', () => {
testSaga(searchAllWorker, searchAll('test', {}))
testSaga(searchAllWorker, searchAll('test', ResourceType.table, 0))
.next().throw(new Error()).put(searchAllFailure())
.next().isDone();
});
......@@ -185,14 +246,14 @@ describe('search ducks', () => {
const pageIndex = 0;
const resource = ResourceType.table;
const term = 'test';
testSaga(searchResourceWorker, searchResource(resource, term, pageIndex))
testSaga(searchResourceWorker, searchResource(term, resource, pageIndex))
.next().call(API.searchResource, pageIndex, resource, term)
.next(expectedSearchResults).put(searchResourceSuccess(expectedSearchResults))
.next().isDone();
});
it('handles request error', () => {
testSaga(searchResourceWorker, searchResource(ResourceType.table, 'test', 0))
testSaga(searchResourceWorker, searchResource('test', ResourceType.table, 0))
.next().throw(new Error()).put(searchResourceFailure())
.next().isDone();
});
......
......@@ -2,7 +2,6 @@ import {
DashboardResource,
Resource,
ResourceType,
SearchAllOptions,
TableResource,
UserResource,
} from 'interfaces';
......@@ -18,7 +17,12 @@ export type UserSearchResults = SearchResults<UserResource>;
export interface SearchResponsePayload {
search_term: string;
isLoading: boolean;
dashboards?: DashboardSearchResults;
tables?: TableSearchResults;
users?: UserSearchResults;
};
export interface SearchAllResponsePayload extends SearchResponsePayload {
selectedTab: ResourceType;
dashboards: DashboardSearchResults;
tables: TableSearchResults;
users: UserSearchResults;
......@@ -32,14 +36,15 @@ export enum SearchAll {
};
export interface SearchAllRequest {
payload: {
options: SearchAllOptions;
resource: ResourceType;
pageIndex: number;
term: string;
};
type: SearchAll.REQUEST;
};
export interface SearchAllResponse {
type: SearchAll.SUCCESS | SearchAll.FAILURE;
payload?: SearchResponsePayload;
payload?: SearchAllResponsePayload;
};
export interface SearchAllReset {
type: SearchAll.RESET;
......@@ -62,3 +67,13 @@ export interface SearchResourceResponse {
type: SearchResource.SUCCESS | SearchResource.FAILURE;
payload?: SearchResponsePayload;
};
export enum UpdateSearchTab {
REQUEST = 'amundsen/search/UPDATE_SEARCH_TAB_REQUEST',
}
export interface UpdateSearchTabRequest {
type: UpdateSearchTab.REQUEST;
payload: {
selectedTab: ResourceType;
}
}
......@@ -54,6 +54,7 @@ const globalState: GlobalState = {
],
search: {
search_term: 'testName',
selectedTab: ResourceType.table,
isLoading: false,
dashboards: {
page_index: 0,
......
......@@ -26,7 +26,7 @@ export function getMockRouterProps<P>(data: P, location: Partial<History.Locatio
action: 'POP',
location: mockLocation,
push: () => {},
replace: null,
replace: () => {},
go: null,
goBack: null,
goForward: null,
......
export interface SearchAllOptions {
dashboardIndex?: number;
tableIndex?: number;
userIndex?: number;
}
......@@ -2,7 +2,6 @@ export * from './Announcements';
export * from './Enums';
export * from './Feedback';
export * from './Resources';
export * from './Search';
export * from './TableMetadata';
export * from './Tags';
export * from './User';
......@@ -6517,7 +6517,7 @@
},
"tar": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-4.4.1.tgz",
"resolved": false,
"integrity": "sha512-O+v1r9yN4tOsvl90p5HAP4AEqbYhx4036AGMm075fH9F8Qwi3oJ+v4u50FkT/KkvywNGtwkk0zRI+8eYm1X/xg==",
"optional": true,
"requires": {
......@@ -13306,7 +13306,7 @@
"react-avatar": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/react-avatar/-/react-avatar-2.5.1.tgz",
"integrity": "sha512-bwH5pWY6uxaKZt+IZBfD+SU3Dpy3FaKbmAzrOI4N8SATUPLXOdGaJHWUl6Vl8hHSwWSsoLh/m7xYHdnn0lofZw==",
"integrity": "sha1-W76MHQpSWT1GCPs9hinamV7rcJU=",
"requires": {
"babel-runtime": ">=5.0.0",
"is-retina": "^1.0.3",
......
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