mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #256 from acelaya-forks/feature/settings-page
Feature/settings page
This commit is contained in:
commit
9ba8bc8f3d
25 changed files with 311 additions and 69 deletions
|
@ -15,6 +15,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
|
* If it works, it will setup the necessary `EventSource`s, dispatching redux actions when an event is pushed, which will in turn update the UI.
|
||||||
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
|
* If it fails, it will assume it is either not configured or not supported by the Shlink version.
|
||||||
|
|
||||||
|
* [#253](https://github.com/shlinkio/shlink-web-client/issues/253) Created new settings page that will be used to define customizations in the app.
|
||||||
|
|
||||||
#### Changed
|
#### Changed
|
||||||
|
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
37
src/App.js
37
src/App.js
|
@ -1,22 +1,29 @@
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Route, Switch } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import './App.scss';
|
|
||||||
import NotFound from './common/NotFound';
|
import NotFound from './common/NotFound';
|
||||||
|
import './App.scss';
|
||||||
|
|
||||||
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer) => () => (
|
const App = (MainHeader, Home, MenuLayout, CreateServer, EditServer, Settings) => ({ loadRealTimeUpdates }) => {
|
||||||
<div className="container-fluid app-container">
|
useEffect(() => {
|
||||||
<MainHeader />
|
loadRealTimeUpdates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
<div className="app">
|
return (
|
||||||
<Switch>
|
<div className="container-fluid app-container">
|
||||||
<Route exact path="/" component={Home} />
|
<MainHeader />
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
|
||||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
<div className="app">
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
<Switch>
|
||||||
<Route component={NotFound} />
|
<Route exact path="/" component={Home} />
|
||||||
</Switch>
|
<Route exact path="/settings" component={Settings} />
|
||||||
|
<Route exact path="/server/create" component={CreateServer} />
|
||||||
|
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||||
|
<Route path="/server/:serverId" component={MenuLayout} />
|
||||||
|
<Route component={NotFound} />
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
|
@ -1,37 +1,28 @@
|
||||||
import { faPlus as plusIcon, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import classnames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import shlinkLogo from './shlink-logo-white.png';
|
import shlinkLogo from './shlink-logo-white.png';
|
||||||
import './MainHeader.scss';
|
import './MainHeader.scss';
|
||||||
|
|
||||||
const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
|
const propTypes = {
|
||||||
static propTypes = {
|
location: PropTypes.object,
|
||||||
location: PropTypes.object,
|
};
|
||||||
};
|
|
||||||
|
|
||||||
state = { isOpen: false };
|
const MainHeader = (ServersDropdown) => {
|
||||||
handleToggle = () => {
|
const MainHeaderComp = ({ location }) => {
|
||||||
this.setState(({ isOpen }) => ({
|
const [ isOpen, toggleOpen, , close ] = useToggle();
|
||||||
isOpen: !isOpen,
|
const { pathname } = location;
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
useEffect(close, [ location ]);
|
||||||
if (this.props.location !== prevProps.location) {
|
|
||||||
this.setState({ isOpen: false });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { location } = this.props;
|
|
||||||
const createServerPath = '/server/create';
|
const createServerPath = '/server/create';
|
||||||
const toggleClass = classnames('main-header__toggle-icon', {
|
const settingsPath = '/settings';
|
||||||
'main-header__toggle-icon--opened': this.state.isOpen,
|
const toggleClass = classNames('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isOpen });
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
||||||
|
@ -39,18 +30,19 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
||||||
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
<img src={shlinkLogo} alt="Shlink" className="main-header__brand-logo" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={this.handleToggle}>
|
<NavbarToggler onClick={toggleOpen}>
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={this.state.isOpen}>
|
<Collapse navbar isOpen={isOpen}>
|
||||||
<Nav navbar className="ml-auto">
|
<Nav navbar className="ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink
|
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||||
tag={Link}
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
to={createServerPath}
|
</NavLink>
|
||||||
active={location.pathname === createServerPath}
|
</NavItem>
|
||||||
>
|
<NavItem>
|
||||||
|
<NavLink tag={Link} to={createServerPath} active={pathname === createServerPath}>
|
||||||
<FontAwesomeIcon icon={plusIcon} /> Add server
|
<FontAwesomeIcon icon={plusIcon} /> Add server
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
@ -59,7 +51,11 @@ const MainHeader = (ServersDropdown) => class MainHeader extends React.Component
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
MainHeaderComp.propTypes = propTypes;
|
||||||
|
|
||||||
|
return MainHeaderComp;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MainHeader;
|
export default MainHeader;
|
||||||
|
|
13
src/common/NoMenuLayout.js
Normal file
13
src/common/NoMenuLayout.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
children: PropTypes.node,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NoMenuLayout = ({ children }) => <div className="no-menu-wrapper">{children}</div>;
|
||||||
|
|
||||||
|
NoMenuLayout.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default NoMenuLayout;
|
3
src/common/NoMenuLayout.scss
Normal file
3
src/common/NoMenuLayout.scss
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.no-menu-wrapper {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ import provideVisitsServices from '../visits/services/provideServices';
|
||||||
import provideTagsServices from '../tags/services/provideServices';
|
import provideTagsServices from '../tags/services/provideServices';
|
||||||
import provideUtilsServices from '../utils/services/provideServices';
|
import provideUtilsServices from '../utils/services/provideServices';
|
||||||
import provideMercureServices from '../mercure/services/provideServices';
|
import provideMercureServices from '../mercure/services/provideServices';
|
||||||
|
import provideSettingsServices from '../settings/services/provideServices';
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
const { container } = bottle;
|
||||||
|
@ -27,7 +28,8 @@ const connect = (propsFromState, actionServiceNames = []) =>
|
||||||
actionServiceNames.reduce(mapActionService, {})
|
actionServiceNames.reduce(mapActionService, {})
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer');
|
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer', 'EditServer', 'Settings');
|
||||||
|
bottle.decorator('App', connect(null, [ 'loadRealTimeUpdates' ]));
|
||||||
|
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect);
|
||||||
|
@ -36,5 +38,6 @@ provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
provideUtilsServices(bottle);
|
provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
|
provideSettingsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
export default container;
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
|
|
||||||
export const bindToMercureTopic = (mercureInfo, topic, onMessage, onTokenExpired) => () => {
|
export const bindToMercureTopic = (mercureInfo, realTimeUpdates, topic, onMessage, onTokenExpired) => () => {
|
||||||
|
const { enabled } = realTimeUpdates;
|
||||||
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
const { mercureHubUrl, token, loading, error } = mercureInfo;
|
||||||
|
|
||||||
if (loading || error) {
|
if (!enabled || loading || error) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,7 @@ import tagsListReducer from '../tags/reducers/tagsList';
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
import tagEditReducer from '../tags/reducers/tagEdit';
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
||||||
|
import realTimeUpdatesReducer from '../settings/reducers/realTimeUpdates';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
|
@ -31,4 +32,5 @@ export default combineReducers({
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: tagDeleteReducer,
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: tagEditReducer,
|
||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: mercureInfoReducer,
|
||||||
|
realTimeUpdates: realTimeUpdatesReducer,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import './CreateServer.scss';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
|
import './CreateServer.scss';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
const propTypes = {
|
const propTypes = {
|
||||||
|
@ -29,7 +30,7 @@ const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-server">
|
<NoMenuLayout>
|
||||||
<ServerForm onSubmit={handleSubmit}>
|
<ServerForm onSubmit={handleSubmit}>
|
||||||
<ImportServersBtn onImport={setServersImported} />
|
<ImportServersBtn onImport={setServersImported} />
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
<button className="btn btn-outline-primary">Create server</button>
|
||||||
|
@ -44,7 +45,7 @@ const CreateServer = (ImportServersBtn, useStateFlagTimeout) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,5 @@
|
||||||
@import '../utils/base';
|
@import '../utils/base';
|
||||||
|
|
||||||
.create-server {
|
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-server__label {
|
.create-server__label {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { serverType } from './prop-types';
|
import { serverType } from './prop-types';
|
||||||
|
@ -20,11 +21,11 @@ export const EditServer = (ServerError) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-server">
|
<NoMenuLayout>
|
||||||
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
<ServerForm initialValues={selectedServer} onSubmit={handleSubmit}>
|
||||||
<button className="btn btn-outline-primary">Save</button>
|
<button className="btn btn-outline-primary">Save</button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
</div>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
31
src/settings/RealTimeUpdates.js
Normal file
31
src/settings/RealTimeUpdates.js
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Card, CardBody, CardHeader, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faInfoCircle } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import Checkbox from '../utils/Checkbox';
|
||||||
|
import { RealTimeUpdatesType } from './reducers/realTimeUpdates';
|
||||||
|
|
||||||
|
const propTypes = {
|
||||||
|
realTimeUpdates: RealTimeUpdatesType,
|
||||||
|
setRealTimeUpdates: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
const RealTimeUpdates = ({ realTimeUpdates, setRealTimeUpdates }) => (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>Real-time updates</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Checkbox checked={realTimeUpdates.enabled} onChange={setRealTimeUpdates}>
|
||||||
|
Enable real-time updates
|
||||||
|
<FontAwesomeIcon icon={faInfoCircle} className="ml-2" id="realTimeUpdatesInfo" />
|
||||||
|
</Checkbox>
|
||||||
|
<UncontrolledTooltip target="realTimeUpdatesInfo">
|
||||||
|
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
|
||||||
|
RealTimeUpdates.propTypes = propTypes;
|
||||||
|
|
||||||
|
export default RealTimeUpdates;
|
10
src/settings/Settings.js
Normal file
10
src/settings/Settings.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import React from 'react';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
|
||||||
|
const Settings = (RealTimeUpdates) => () => (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<RealTimeUpdates />
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Settings;
|
32
src/settings/reducers/realTimeUpdates.js
Normal file
32
src/settings/reducers/realTimeUpdates.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { handleActions } from 'redux-actions';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
export const LOAD_REAL_TIME_UPDATES = 'shlink/realTimeUpdates/LOAD_REAL_TIME_UPDATES';
|
||||||
|
|
||||||
|
export const RealTimeUpdatesType = PropTypes.shape({
|
||||||
|
enabled: PropTypes.bool.isRequired,
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default handleActions({
|
||||||
|
[LOAD_REAL_TIME_UPDATES]: (state, { enabled }) => ({ ...state, enabled }),
|
||||||
|
}, initialState);
|
||||||
|
|
||||||
|
export const setRealTimeUpdates = ({ updateSettings }, loadRealTimeUpdatesAction) => (enabled) => {
|
||||||
|
updateSettings({ realTimeUpdates: { enabled } });
|
||||||
|
|
||||||
|
return loadRealTimeUpdatesAction();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadRealTimeUpdates = ({ loadSettings }) => () => {
|
||||||
|
const { realTimeUpdates = {} } = loadSettings();
|
||||||
|
const { enabled = true } = realTimeUpdates;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: LOAD_REAL_TIME_UPDATES,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
};
|
14
src/settings/services/SettingsService.js
Normal file
14
src/settings/services/SettingsService.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
const SETTINGS_STORAGE_KEY = 'settings';
|
||||||
|
|
||||||
|
export default class SettingsService {
|
||||||
|
constructor(storage) {
|
||||||
|
this.storage = storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSettings = () => this.storage.get(SETTINGS_STORAGE_KEY) || {};
|
||||||
|
|
||||||
|
updateSettings = (settingsToUpdate) => this.storage.set(SETTINGS_STORAGE_KEY, {
|
||||||
|
...this.loadSettings(),
|
||||||
|
...settingsToUpdate,
|
||||||
|
})
|
||||||
|
}
|
21
src/settings/services/provideServices.js
Normal file
21
src/settings/services/provideServices.js
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import RealTimeUpdates from '../RealTimeUpdates';
|
||||||
|
import Settings from '../Settings';
|
||||||
|
import { loadRealTimeUpdates, setRealTimeUpdates } from '../reducers/realTimeUpdates';
|
||||||
|
import SettingsService from './SettingsService';
|
||||||
|
|
||||||
|
const provideServices = (bottle, connect) => {
|
||||||
|
// Components
|
||||||
|
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates');
|
||||||
|
|
||||||
|
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
||||||
|
bottle.decorator('RealTimeUpdates', connect([ 'realTimeUpdates' ], [ 'setRealTimeUpdates' ]));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
bottle.service('SettingsService', SettingsService, 'Storage');
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
bottle.serviceFactory('setRealTimeUpdates', setRealTimeUpdates, 'SettingsService', 'loadRealTimeUpdates');
|
||||||
|
bottle.serviceFactory('loadRealTimeUpdates', loadRealTimeUpdates, 'SettingsService');
|
||||||
|
};
|
||||||
|
|
||||||
|
export default provideServices;
|
|
@ -9,6 +9,7 @@ import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir } from '../utils/utils';
|
import { determineOrderDir } from '../utils/utils';
|
||||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
import { bindToMercureTopic } from '../mercure/helpers';
|
import { bindToMercureTopic } from '../mercure/helpers';
|
||||||
|
import { RealTimeUpdatesType } from '../settings/reducers/realTimeUpdates';
|
||||||
import { shortUrlType } from './reducers/shortUrlsList';
|
import { shortUrlType } from './reducers/shortUrlsList';
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
import './ShortUrlsList.scss';
|
import './ShortUrlsList.scss';
|
||||||
|
@ -33,6 +34,7 @@ const propTypes = {
|
||||||
createNewVisit: PropTypes.func,
|
createNewVisit: PropTypes.func,
|
||||||
loadMercureInfo: PropTypes.func,
|
loadMercureInfo: PropTypes.func,
|
||||||
mercureInfo: MercureInfoType,
|
mercureInfo: MercureInfoType,
|
||||||
|
realTimeUpdates: RealTimeUpdatesType,
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXME Replace with typescript: (ShortUrlsRow component)
|
// FIXME Replace with typescript: (ShortUrlsRow component)
|
||||||
|
@ -50,6 +52,7 @@ const ShortUrlsList = (ShortUrlsRow) => {
|
||||||
createNewVisit,
|
createNewVisit,
|
||||||
loadMercureInfo,
|
loadMercureInfo,
|
||||||
mercureInfo,
|
mercureInfo,
|
||||||
|
realTimeUpdates,
|
||||||
}) => {
|
}) => {
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState({
|
const [ order, setOrder ] = useState({
|
||||||
|
@ -117,7 +120,7 @@ const ShortUrlsList = (ShortUrlsRow) => {
|
||||||
return resetShortUrlParams;
|
return resetShortUrlParams;
|
||||||
}, []);
|
}, []);
|
||||||
useEffect(
|
useEffect(
|
||||||
bindToMercureTopic(mercureInfo, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
bindToMercureTopic(mercureInfo, realTimeUpdates, 'https://shlink.io/new-visit', createNewVisit, loadMercureInfo),
|
||||||
[ mercureInfo ]
|
[ mercureInfo ]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -31,7 +31,7 @@ const provideServices = (bottle, connect) => {
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'realTimeUpdates' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit', 'loadMercureInfo' ]
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { formatDate } from '../utils/helpers/date';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
import { bindToMercureTopic } from '../mercure/helpers';
|
import { bindToMercureTopic } from '../mercure/helpers';
|
||||||
|
import { RealTimeUpdatesType } from '../settings/reducers/realTimeUpdates';
|
||||||
import SortableBarGraph from './SortableBarGraph';
|
import SortableBarGraph from './SortableBarGraph';
|
||||||
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
|
||||||
import VisitsHeader from './VisitsHeader';
|
import VisitsHeader from './VisitsHeader';
|
||||||
|
@ -35,6 +36,7 @@ const propTypes = {
|
||||||
createNewVisit: PropTypes.func,
|
createNewVisit: PropTypes.func,
|
||||||
loadMercureInfo: PropTypes.func,
|
loadMercureInfo: PropTypes.func,
|
||||||
mercureInfo: MercureInfoType,
|
mercureInfo: MercureInfoType,
|
||||||
|
realTimeUpdates: RealTimeUpdatesType,
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
|
const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.reduce((acc, highlightedVisit) => {
|
||||||
|
@ -62,6 +64,7 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
|
||||||
createNewVisit,
|
createNewVisit,
|
||||||
loadMercureInfo,
|
loadMercureInfo,
|
||||||
mercureInfo,
|
mercureInfo,
|
||||||
|
realTimeUpdates,
|
||||||
}) => {
|
}) => {
|
||||||
const [ startDate, setStartDate ] = useState(undefined);
|
const [ startDate, setStartDate ] = useState(undefined);
|
||||||
const [ endDate, setEndDate ] = useState(undefined);
|
const [ endDate, setEndDate ] = useState(undefined);
|
||||||
|
@ -117,7 +120,13 @@ const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModa
|
||||||
loadVisits();
|
loadVisits();
|
||||||
}, [ startDate, endDate ]);
|
}, [ startDate, endDate ]);
|
||||||
useEffect(
|
useEffect(
|
||||||
bindToMercureTopic(mercureInfo, `https://shlink.io/new-visit/${shortCode}`, createNewVisit, loadMercureInfo),
|
bindToMercureTopic(
|
||||||
|
mercureInfo,
|
||||||
|
realTimeUpdates,
|
||||||
|
`https://shlink.io/new-visit/${shortCode}`,
|
||||||
|
createNewVisit,
|
||||||
|
loadMercureInfo
|
||||||
|
),
|
||||||
[ mercureInfo ],
|
[ mercureInfo ],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ const provideServices = (bottle, connect) => {
|
||||||
bottle.serviceFactory('MapModal', () => MapModal);
|
bottle.serviceFactory('MapModal', () => MapModal);
|
||||||
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
|
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser', 'OpenMapModalBtn');
|
||||||
bottle.decorator('ShortUrlVisits', connect(
|
bottle.decorator('ShortUrlVisits', connect(
|
||||||
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo' ],
|
[ 'shortUrlVisits', 'shortUrlDetail', 'mercureInfo', 'realTimeUpdates' ],
|
||||||
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
[ 'getShortUrlVisits', 'getShortUrlDetail', 'cancelGetShortUrlVisits', 'createNewVisit', 'loadMercureInfo' ]
|
||||||
));
|
));
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,7 @@ describe('<App />', () => {
|
||||||
const routes = wrapper.find(Route);
|
const routes = wrapper.find(Route);
|
||||||
const expectedPaths = [
|
const expectedPaths = [
|
||||||
'/',
|
'/',
|
||||||
|
'/settings',
|
||||||
'/server/create',
|
'/server/create',
|
||||||
'/server/:serverId/edit',
|
'/server/:serverId/edit',
|
||||||
'/server/:serverId',
|
'/server/:serverId',
|
||||||
|
|
|
@ -11,11 +11,12 @@ describe('helpers', () => {
|
||||||
const onTokenExpired = jest.fn();
|
const onTokenExpired = jest.fn();
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{ loading: true, error: false }],
|
[{ loading: true, error: false }, { enabled: true }],
|
||||||
[{ loading: false, error: true }],
|
[{ loading: false, error: true }, { enabled: true }],
|
||||||
[{ loading: true, error: true }],
|
[{ loading: true, error: true }, { enabled: true }],
|
||||||
])('does not bind an EventSource when loading or error', (mercureInfo) => {
|
[{ loading: false, error: false }, { enabled: false }],
|
||||||
bindToMercureTopic(mercureInfo)();
|
])('does not bind an EventSource when disabled, loading or error', (mercureInfo, realTimeUpdates) => {
|
||||||
|
bindToMercureTopic(mercureInfo, realTimeUpdates)();
|
||||||
|
|
||||||
expect(EventSource).not.toHaveBeenCalled();
|
expect(EventSource).not.toHaveBeenCalled();
|
||||||
expect(onMessage).not.toHaveBeenCalled();
|
expect(onMessage).not.toHaveBeenCalled();
|
||||||
|
@ -35,7 +36,7 @@ describe('helpers', () => {
|
||||||
error: false,
|
error: false,
|
||||||
mercureHubUrl,
|
mercureHubUrl,
|
||||||
token,
|
token,
|
||||||
}, topic, onMessage, onTokenExpired)();
|
}, { enabled: true }, topic, onMessage, onTokenExpired)();
|
||||||
|
|
||||||
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
expect(EventSource).toHaveBeenCalledWith(hubUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
@ -4,7 +4,9 @@ import reducer, {
|
||||||
deleteServer,
|
deleteServer,
|
||||||
listServers,
|
listServers,
|
||||||
createServers,
|
createServers,
|
||||||
FETCH_SERVERS, FETCH_SERVERS_START, editServer,
|
editServer,
|
||||||
|
FETCH_SERVERS,
|
||||||
|
FETCH_SERVERS_START,
|
||||||
} from '../../../src/servers/reducers/server';
|
} from '../../../src/servers/reducers/server';
|
||||||
|
|
||||||
describe('serverReducer', () => {
|
describe('serverReducer', () => {
|
||||||
|
|
47
test/settings/reducers/realTimeUpdates.test.js
Normal file
47
test/settings/reducers/realTimeUpdates.test.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import reducer, {
|
||||||
|
LOAD_REAL_TIME_UPDATES,
|
||||||
|
loadRealTimeUpdates,
|
||||||
|
setRealTimeUpdates,
|
||||||
|
} from '../../../src/settings/reducers/realTimeUpdates';
|
||||||
|
|
||||||
|
describe('realTimeUpdatesReducer', () => {
|
||||||
|
const SettingsServiceMock = {
|
||||||
|
updateSettings: jest.fn(),
|
||||||
|
loadSettings: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
describe('reducer', () => {
|
||||||
|
it('returns realTimeUpdates when action is LOAD_REAL_TIME_UPDATES', () => {
|
||||||
|
expect(reducer({}, { type: LOAD_REAL_TIME_UPDATES, enabled: true })).toEqual({ enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadRealTimeUpdates', () => {
|
||||||
|
it.each([
|
||||||
|
[{}, true ],
|
||||||
|
[{ realTimeUpdates: {} }, true ],
|
||||||
|
[{ realTimeUpdates: { enabled: true } }, true ],
|
||||||
|
[{ realTimeUpdates: { enabled: false } }, false ],
|
||||||
|
])('loads settings and returns LOAD_REAL_TIME_UPDATES action', (loadedSettings, expectedEnabled) => {
|
||||||
|
SettingsServiceMock.loadSettings.mockReturnValue(loadedSettings);
|
||||||
|
|
||||||
|
const result = loadRealTimeUpdates(SettingsServiceMock)();
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: LOAD_REAL_TIME_UPDATES, enabled: expectedEnabled });
|
||||||
|
expect(SettingsServiceMock.loadSettings).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setRealTimeUpdates', () => {
|
||||||
|
it.each([[ true ], [ false ]])('updates settings with provided value and then loads updates again', (enabled) => {
|
||||||
|
const loadRealTimeUpdatesAction = jest.fn();
|
||||||
|
|
||||||
|
setRealTimeUpdates(SettingsServiceMock, loadRealTimeUpdatesAction)(enabled);
|
||||||
|
|
||||||
|
expect(SettingsServiceMock.updateSettings).toHaveBeenCalledWith({ realTimeUpdates: { enabled } });
|
||||||
|
expect(loadRealTimeUpdatesAction).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
46
test/settings/services/SettingsService.test.js
Normal file
46
test/settings/services/SettingsService.test.js
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import SettingsService from '../../../src/settings/services/SettingsService';
|
||||||
|
|
||||||
|
describe('SettingsService', () => {
|
||||||
|
const settings = { foo: 'bar' };
|
||||||
|
const createService = (withSettings = true) => {
|
||||||
|
const storageMock = {
|
||||||
|
set: jest.fn(),
|
||||||
|
get: jest.fn(() => withSettings ? settings : undefined),
|
||||||
|
};
|
||||||
|
const service = new SettingsService(storageMock);
|
||||||
|
|
||||||
|
return [ service, storageMock ];
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(jest.resetAllMocks);
|
||||||
|
|
||||||
|
describe('loadSettings', () => {
|
||||||
|
it.each([
|
||||||
|
[ false, {}],
|
||||||
|
[ true, settings ],
|
||||||
|
])('returns result if found in storage', (withSettings, expectedResult) => {
|
||||||
|
const [ service, storageMock ] = createService(withSettings);
|
||||||
|
|
||||||
|
const result = service.loadSettings();
|
||||||
|
|
||||||
|
expect(result).toEqual(expectedResult);
|
||||||
|
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageMock.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateSettings', () => {
|
||||||
|
it.each([
|
||||||
|
[ false, { hi: 'goodbye' }, { hi: 'goodbye' }],
|
||||||
|
[ true, { hi: 'goodbye' }, { foo: 'bar', hi: 'goodbye' }],
|
||||||
|
[ true, { foo: 'goodbye' }, { foo: 'goodbye' }],
|
||||||
|
])('appends provided data to existing settings', (withSettings, settingsToUpdate, expectedResult) => {
|
||||||
|
const [ service, storageMock ] = createService(withSettings);
|
||||||
|
|
||||||
|
service.updateSettings(settingsToUpdate);
|
||||||
|
|
||||||
|
expect(storageMock.get).toHaveBeenCalledTimes(1);
|
||||||
|
expect(storageMock.set).toHaveBeenCalledWith(expect.anything(), expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue