Merge pull request #214 from acelaya-forks/feature/consistent-server-loading

Feature/consistent server loading
This commit is contained in:
Alejandro Celaya 2020-03-05 09:32:59 +01:00 committed by GitHub
commit 451c77d47f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 129 additions and 166 deletions

View file

@ -1,110 +1,85 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Swipeable } from 'react-swipeable';
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
import classNames from 'classnames';
import * as PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types';
import MutedMessage from '../utils/MutedMessage';
import NotFound from './NotFound';
import './MenuLayout.scss';
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
const propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
location: PropTypes.object,
selectedServer: serverType,
};
state = { showSideBar: false };
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) => {
const MenuLayoutComp = ({ match, location, selectedServer, selectServer }) => {
const [ showSideBar, setShowSidebar ] = useState(false);
componentDidMount() {
const { match, selectServer } = this.props;
useEffect(() => {
const { params: { serverId } } = match;
selectServer(serverId);
}, []);
useEffect(() => setShowSidebar(false), [ location ]);
if (!selectedServer) {
return <MutedMessage loading />;
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
const { params: { serverId } } = match;
const burgerClasses = classNames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
}
render() {
const { selectedServer, match } = this.props;
const { params: { serverId } } = match;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
const swipeMenuIfNoModalExists = (showSideBar) => () => {
if (document.querySelector('.modal')) {
return;
}
setShowSidebar(showSideBar);
};
this.setState({ showSideBar });
};
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => setShowSidebar(!showSideBar)}
/>
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu
className="col-lg-2 col-md-3"
selectedServer={selectedServer}
showOnMobile={this.state.showSideBar}
/>
<div
className="col-lg-10 offset-lg-2 col-md-9 offset-md-3"
onClick={() => this.setState({ showSideBar: false })}
>
<Switch>
<Route
exact
path="/server/:serverId/list-short-urls/:page"
component={ShortUrls}
/>
<Route
exact
path="/server/:serverId/create-short-url"
component={CreateShortUrl}
/>
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={swipeMenuIfNoModalExists(false)}
onSwipedRight={swipeMenuIfNoModalExists(true)}
>
<div className="row menu-layout__swipeable-inner">
<AsideMenu className="col-lg-2 col-md-3" selectedServer={selectedServer} showOnMobile={showSideBar} />
<div className="col-lg-10 offset-lg-2 col-md-9 offset-md-3" onClick={() => setShowSidebar(false)}>
<Switch>
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
<Route exact path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
<Route
render={() => <NotFound to={`/server/${serverId}/list-short-urls/1`} btnText="List short URLs" />}
/>
</Switch>
</div>
</Swipeable>
</React.Fragment>
);
}
</div>
</Swipeable>
</React.Fragment>
);
};
MenuLayoutComp.propTypes = propTypes;
return MenuLayoutComp;
};
export default MenuLayout;

View file

@ -19,7 +19,7 @@ export const selectServer = ({ findServerById }, buildShlinkApiClient) => (serve
dispatch(resetShortUrlParams());
const selectedServer = findServerById(serverId);
const { health } = await buildShlinkApiClient(selectedServer);
const { health } = buildShlinkApiClient(selectedServer);
const version = await health()
.then(({ version }) => version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version)
.then((version) => !versionIsValidSemVer(version) ? MIN_FALLBACK_VERSION : version)

View file

@ -31,8 +31,7 @@ export default handleActions({
export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START });
const { createShortUrl } = await buildShlinkApiClient(getState);
const { createShortUrl } = buildShlinkApiClient(getState);
try {
const result = await createShortUrl(data);

View file

@ -32,8 +32,7 @@ export default handleActions({
export const deleteShortUrl = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START });
const { deleteShortUrl } = await buildShlinkApiClient(getState);
const { deleteShortUrl } = buildShlinkApiClient(getState);
try {
await deleteShortUrl(shortCode, domain);

View file

@ -37,7 +37,7 @@ export default handleActions({
export const editShortUrlMeta = (buildShlinkApiClient) => (shortCode, domain, meta) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_META_START });
const { updateShortUrlMeta } = await buildShlinkApiClient(getState);
const { updateShortUrlMeta } = buildShlinkApiClient(getState);
try {
await updateShortUrlMeta(shortCode, domain, meta);

View file

@ -31,7 +31,7 @@ export default handleActions({
export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, domain, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { updateShortUrlTags } = await buildShlinkApiClient(getState);
const { updateShortUrlTags } = buildShlinkApiClient(getState);
try {
const normalizedTags = await updateShortUrlTags(shortCode, domain, tags);

View file

@ -58,8 +58,7 @@ export default handleActions({
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START });
const { listShortUrls } = await buildShlinkApiClient(getState);
const { listShortUrls } = buildShlinkApiClient(getState);
try {
const shortUrls = await listShortUrls(params);

View file

@ -1,7 +1,7 @@
import React from 'react';
import { splitEvery } from 'ramda';
import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage';
import MutedMessage from '../utils/MutedMessage';
import SearchField from '../utils/SearchField';
const { ceil } = Math;
@ -29,7 +29,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const { tagsList, match } = this.props;
if (tagsList.loading) {
return <MuttedMessage marginSize={0}>Loading...</MuttedMessage>;
return <MutedMessage noMargin loading />;
}
if (tagsList.error) {
@ -43,7 +43,7 @@ const TagsList = (TagCard) => class TagsList extends React.Component {
const tagsCount = tagsList.filteredTags.length;
if (tagsCount < 1) {
return <MuttedMessage>No tags found</MuttedMessage>;
return <MutedMessage>No tags found</MutedMessage>;
}
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);

View file

@ -26,8 +26,7 @@ export default handleActions({
export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START });
const { deleteTags } = await buildShlinkApiClient(getState);
const { deleteTags } = buildShlinkApiClient(getState);
try {
await deleteTags([ tag ]);

View file

@ -31,8 +31,7 @@ export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newNa
getState
) => {
dispatch({ type: EDIT_TAG_START });
const { editTag } = await buildShlinkApiClient(getState);
const { editTag } = buildShlinkApiClient(getState);
try {
await editTag(oldName, newName);

View file

@ -50,7 +50,7 @@ export const listTags = (buildShlinkApiClient, force = true) => () => async (dis
dispatch({ type: LIST_TAGS_START });
try {
const { listTags } = await buildShlinkApiClient(getState);
const { listTags } = buildShlinkApiClient(getState);
const tags = await listTags();
dispatch({ tags, type: LIST_TAGS });

34
src/utils/MutedMessage.js Normal file
View file

@ -0,0 +1,34 @@
import React from 'react';
import { Card } from 'reactstrap';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
const propTypes = {
noMargin: PropTypes.bool,
loading: PropTypes.bool,
children: PropTypes.node,
};
const MutedMessage = ({ children, loading = false, noMargin = false }) => {
const cardClasses = classNames('bg-light', {
'mt-4': !noMargin,
});
return (
<div className="col-md-10 offset-md-1">
<Card className={cardClasses} body>
<h3 className="text-center text-muted mb-0">
{loading && <FontAwesomeIcon icon={preloader} spin />}
{loading && !children && <span className="ml-2">Loading...</span>}
{children}
</h3>
</Card>
</div>
);
};
MutedMessage.propTypes = propTypes;
export default MutedMessage;

View file

@ -1,28 +0,0 @@
import React from 'react';
import { Card } from 'reactstrap';
import classnames from 'classnames';
import PropTypes from 'prop-types';
const DEFAULT_MARGIN_SIZE = 4;
const propTypes = {
marginSize: PropTypes.number,
children: PropTypes.node,
};
export default function MutedMessage({ children, marginSize = DEFAULT_MARGIN_SIZE }) {
const cardClasses = classnames('bg-light', {
[`mt-${marginSize}`]: marginSize > 0,
});
return (
<div className="col-md-10 offset-md-1">
<Card className={cardClasses} body>
<h3 className="text-center text-muted mb-0">
{children}
</h3>
</Card>
</div>
);
}
MutedMessage.propTypes = propTypes;

View file

@ -1,21 +1,16 @@
import { wait } from '../utils';
import ShlinkApiClient from './ShlinkApiClient';
const apiClients = {};
const getSelectedServerFromState = async (getState) => {
const getSelectedServerFromState = (getState) => {
const { selectedServer } = getState();
if (!selectedServer) {
return wait(250).then(() => getSelectedServerFromState(getState));
}
return selectedServer;
};
const buildShlinkApiClient = (axios) => async (getStateOrSelectedServer) => {
const buildShlinkApiClient = (axios) => (getStateOrSelectedServer) => {
const { url, apiKey } = typeof getStateOrSelectedServer === 'function'
? await getSelectedServerFromState(getStateOrSelectedServer)
? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const clientKey = `${url}_${apiKey}`;

View file

@ -53,8 +53,6 @@ export const useToggle = (initialValue = false) => {
return [ flag, () => setFlag(!flag) ];
};
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
export const compareVersions = (firstVersion, operator, secondVersion) => compare(
firstVersion,
secondVersion,

View file

@ -1,12 +1,10 @@
import { faCircleNotch as preloader } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, values } from 'ramda';
import React from 'react';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import qs from 'qs';
import DateRangeRow from '../utils/DateRangeRow';
import MutedMessage from '../utils/MuttedMessage';
import MutedMessage from '../utils/MutedMessage';
import { formatDate } from '../utils/utils';
import SortableBarGraph from './SortableBarGraph';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
@ -66,7 +64,7 @@ const ShortUrlVisits = (
if (loading) {
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
return <MutedMessage><FontAwesomeIcon icon={preloader} spin /> {message}</MutedMessage>;
return <MutedMessage loading>{message}</MutedMessage>;
}
if (error) {

View file

@ -28,8 +28,7 @@ export default handleActions({
export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode, domain) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { getShortUrl } = await buildShlinkApiClient(getState);
const { getShortUrl } = buildShlinkApiClient(getState);
try {
const shortUrl = await getShortUrl(shortCode, domain);

View file

@ -51,8 +51,7 @@ export default handleActions({
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START });
const { getShortUrlVisits } = await buildShlinkApiClient(getState);
const { getShortUrlVisits } = buildShlinkApiClient(getState);
const itemsPerPage = 5000;
const isLastPage = ({ currentPage, pagesCount }) => currentPage >= pagesCount;

View file

@ -38,7 +38,7 @@ describe('selectedServerReducer', () => {
const apiClientMock = {
health: jest.fn(),
};
const buildApiClient = jest.fn().mockResolvedValue(apiClientMock);
const buildApiClient = jest.fn().mockReturnValue(apiClientMock);
const dispatch = jest.fn();
afterEach(jest.clearAllMocks);

View file

@ -51,7 +51,7 @@ describe('shortUrlMetaReducer', () => {
describe('editShortUrlMeta', () => {
const updateShortUrlMeta = jest.fn().mockResolvedValue({});
const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlMeta });
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlMeta });
const dispatch = jest.fn();
afterEach(jest.clearAllMocks);

View file

@ -51,14 +51,10 @@ describe('shortUrlTagsReducer', () => {
describe('editShortUrlTags', () => {
const updateShortUrlTags = jest.fn();
const buildShlinkApiClient = jest.fn().mockResolvedValue({ updateShortUrlTags });
const buildShlinkApiClient = jest.fn().mockReturnValue({ updateShortUrlTags });
const dispatch = jest.fn();
afterEach(() => {
updateShortUrlTags.mockReset();
buildShlinkApiClient.mockClear();
dispatch.mockReset();
});
afterEach(jest.clearAllMocks);
it.each([[ undefined ], [ null ], [ 'example.com' ]])('dispatches normalized tags on success', async (domain) => {
const normalizedTags = [ 'bar', 'foo' ];

View file

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
import createTagsList from '../../src/tags/TagsList';
import MuttedMessage from '../../src/utils/MuttedMessage';
import MutedMessage from '../../src/utils/MutedMessage';
import SearchField from '../../src/utils/SearchField';
import { rangeOf } from '../../src/utils/utils';
@ -28,7 +28,7 @@ describe('<TagsList />', () => {
it('shows a loading message when tags are being loaded', () => {
const wrapper = createWrapper({ loading: true });
const loadingMsg = wrapper.find(MuttedMessage);
const loadingMsg = wrapper.find(MutedMessage);
expect(loadingMsg).toHaveLength(1);
expect(loadingMsg.html()).toContain('Loading...');
@ -44,7 +44,7 @@ describe('<TagsList />', () => {
it('shows a message when the list of tags is empty', () => {
const wrapper = createWrapper({ filteredTags: [] });
const msg = wrapper.find(MuttedMessage);
const msg = wrapper.find(MutedMessage);
expect(msg).toHaveLength(1);
expect(msg.html()).toContain('No tags found');

View file

@ -104,7 +104,7 @@ describe('tagsListReducer', () => {
const tags = [ 'foo', 'bar', 'baz' ];
listTagsMock.mockResolvedValue(tags);
buildShlinkApiClient.mockResolvedValue({ listTags: listTagsMock });
buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock });
await listTags(buildShlinkApiClient, true)()(dispatch, getState);
@ -127,7 +127,7 @@ describe('tagsListReducer', () => {
it('dispatches error when error occurs on list call', async () => {
listTagsMock.mockRejectedValue(new Error());
buildShlinkApiClient.mockResolvedValue({ listTags: listTagsMock });
buildShlinkApiClient.mockReturnValue({ listTags: listTagsMock });
await assertErrorResult();
@ -135,7 +135,9 @@ describe('tagsListReducer', () => {
});
it('dispatches error when error occurs on build call', async () => {
buildShlinkApiClient.mockRejectedValue(new Error());
buildShlinkApiClient.mockImplementation(() => {
throw new Error();
});
await assertErrorResult();

View file

@ -34,10 +34,10 @@ describe('ShlinkApiClientBuilder', () => {
expect(secondApiClient).toBe(thirdApiClient);
});
it('does not fetch from state when provided param is already selected server', async () => {
it('does not fetch from state when provided param is already selected server', () => {
const url = 'url';
const apiKey = 'apiKey';
const apiClient = await buildShlinkApiClient({})({ url, apiKey });
const apiClient = buildShlinkApiClient({})({ url, apiKey });
expect(apiClient._baseUrl).toEqual(url);
expect(apiClient._apiKey).toEqual(apiKey);

View file

@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
import { identity } from 'ramda';
import { Card } from 'reactstrap';
import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
import MutedMessage from '../../src/utils/MuttedMessage';
import MutedMessage from '../../src/utils/MutedMessage';
import GraphCard from '../../src/visits/GraphCard';
import SortableBarGraph from '../../src/visits/SortableBarGraph';
import DateRangeRow from '../../src/utils/DateRangeRow';