Merge pull request #81 from acelaya/feature/dependency-injection

Feature/dependency injection
This commit is contained in:
Alejandro Celaya 2018-12-18 20:31:25 +01:00 committed by GitHub
commit a2b08277dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
86 changed files with 833 additions and 717 deletions

View file

@ -4,6 +4,29 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
#### Added
* *Nothing*
#### Changed
* [#80](https://github.com/shlinkio/shlink-web-client/issues/80) Deeply refactored app to do true dependency injection with an IoC container.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.2.0 - 2018-11-01 ## 1.2.0 - 2018-11-01
#### Added #### Added

View file

@ -1,9 +1,11 @@
module.exports = { module.exports = {
coverageDirectory: '<rootDir>/coverage', coverageDirectory: '<rootDir>/coverage',
collectCoverageFrom: [ collectCoverageFrom: [
'src/**/*.{js,jsx,mjs}', 'src/**/*.js',
'!src/registerServiceWorker.js', '!src/registerServiceWorker.js',
'!src/index.js', '!src/index.js',
'!src/**/provideServices.js',
'!src/container/*.js',
], ],
setupFiles: [ setupFiles: [
'<rootDir>/config/polyfills.js', '<rootDir>/config/polyfills.js',

View file

@ -24,6 +24,7 @@
"@fortawesome/react-fontawesome": "0.0.19", "@fortawesome/react-fontawesome": "0.0.19",
"axios": "^0.18.0", "axios": "^0.18.0",
"bootstrap": "^4.1.1", "bootstrap": "^4.1.1",
"bottlejs": "^1.7.1",
"chart.js": "^2.7.2", "chart.js": "^2.7.2",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"csvjson": "^5.1.0", "csvjson": "^5.1.0",

View file

@ -1,23 +1,19 @@
import React from 'react'; import React from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import './App.scss'; import './App.scss';
import Home from './common/Home';
import MainHeader from './common/MainHeader';
import MenuLayout from './common/MenuLayout';
import CreateServer from './servers/CreateServer';
export default function App() { const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
return ( <div className="container-fluid app-container">
<div className="container-fluid app-container"> <MainHeader />
<MainHeader />
<div className="app"> <div className="app">
<Switch> <Switch>
<Route exact path="/server/create" component={CreateServer} /> <Route exact path="/server/create" component={CreateServer} />
<Route exact path="/" component={Home} /> <Route exact path="/" component={Home} />
<Route path="/server/:serverId" component={MenuLayout} /> <Route path="/server/:serverId" component={MenuLayout} />
</Switch> </Switch>
</div>
</div> </div>
); </div>
} );
export default App;

View file

@ -6,9 +6,8 @@ import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classnames from 'classnames'; import classnames from 'classnames';
import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = { const defaultProps = {
className: '', className: '',
@ -20,51 +19,57 @@ const propTypes = {
showOnMobile: PropTypes.bool, showOnMobile: PropTypes.bool,
}; };
export default function AsideMenu({ selectedServer, className, showOnMobile }) { const AsideMenu = (DeleteServerButton) => {
const serverId = selectedServer ? selectedServer.id : ''; const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const asideClass = classnames('aside-menu', className, { const serverId = selectedServer ? selectedServer.id : '';
'aside-menu--hidden': !showOnMobile, const asideClass = classnames('aside-menu', className, {
}); 'aside-menu--hidden': !showOnMobile,
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls'); });
const shortUrlsIsActive = (match, location) => location.pathname.match('/list-short-urls');
return ( return (
<aside className={asideClass}> <aside className={asideClass}>
<nav className="nav flex-column aside-menu__nav"> <nav className="nav flex-column aside-menu__nav">
<NavLink <NavLink
className="aside-menu__item" className="aside-menu__item"
activeClassName="aside-menu__item--selected" activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/list-short-urls/1`} to={`/server/${serverId}/list-short-urls/1`}
isActive={shortUrlsIsActive} isActive={shortUrlsIsActive}
> >
<FontAwesomeIcon icon={listIcon} /> <FontAwesomeIcon icon={listIcon} />
<span className="aside-menu__item-text">List short URLs</span> <span className="aside-menu__item-text">List short URLs</span>
</NavLink> </NavLink>
<NavLink <NavLink
className="aside-menu__item" className="aside-menu__item"
activeClassName="aside-menu__item--selected" activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/create-short-url`} to={`/server/${serverId}/create-short-url`}
> >
<FontAwesomeIcon icon={createIcon} flip="horizontal" /> <FontAwesomeIcon icon={createIcon} flip="horizontal" />
<span className="aside-menu__item-text">Create short URL</span> <span className="aside-menu__item-text">Create short URL</span>
</NavLink> </NavLink>
<NavLink <NavLink
className="aside-menu__item" className="aside-menu__item"
activeClassName="aside-menu__item--selected" activeClassName="aside-menu__item--selected"
to={`/server/${serverId}/manage-tags`} to={`/server/${serverId}/manage-tags`}
> >
<FontAwesomeIcon icon={tagsIcon} /> <FontAwesomeIcon icon={tagsIcon} />
<span className="aside-menu__item-text">Manage tags</span> <span className="aside-menu__item-text">Manage tags</span>
</NavLink> </NavLink>
<DeleteServerButton <DeleteServerButton
className="aside-menu__item aside-menu__item--danger" className="aside-menu__item aside-menu__item--danger"
server={selectedServer} server={selectedServer}
/> />
</nav> </nav>
</aside> </aside>
); );
} };
AsideMenu.defaultProps = defaultProps; AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes; AsideMenu.propTypes = propTypes;
return AsideMenu;
};
export default AsideMenu;

View file

@ -1,15 +1,13 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight'; import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap'; import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss'; import './Home.scss';
export class HomeComponent extends React.Component { export default class Home extends React.Component {
static propTypes = { static propTypes = {
resetSelectedServer: PropTypes.func, resetSelectedServer: PropTypes.func,
servers: PropTypes.object, servers: PropTypes.object,
@ -50,7 +48,3 @@ export class HomeComponent extends React.Component {
); );
} }
} }
const Home = connect(pick([ 'servers' ]), { resetSelectedServer })(HomeComponent);
export default Home;

View file

@ -2,15 +2,14 @@ import plusIcon from '@fortawesome/fontawesome-free-solid/faPlus';
import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown'; import arrowIcon from '@fortawesome/fontawesome-free-solid/faChevronDown';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { Link, withRouter } 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 ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
import shlinkLogo from './shlink-logo-white.png'; import shlinkLogo from './shlink-logo-white.png';
import './MainHeader.scss';
export class MainHeaderComponent extends React.Component { const MainHeader = (ServersDropdown) => class MainHeader extends React.Component {
static propTypes = { static propTypes = {
location: PropTypes.object, location: PropTypes.object,
}; };
@ -62,8 +61,6 @@ export class MainHeaderComponent extends React.Component {
</Navbar> </Navbar>
); );
} }
} };
const MainHeader = withRouter(MainHeaderComponent);
export default MainHeader; export default MainHeader;

View file

@ -1,113 +1,100 @@
import React from 'react'; import React from 'react';
import { Route, Switch, withRouter } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { pick } from 'ramda';
import Swipeable from 'react-swipeable'; import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars'; import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import classnames from 'classnames'; import classnames from 'classnames';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import ShortUrlsVisits from '../visits/ShortUrlVisits';
import { selectServer } from '../servers/reducers/selectedServer';
import CreateShortUrl from '../short-urls/CreateShortUrl';
import ShortUrls from '../short-urls/ShortUrls';
import './MenuLayout.scss';
import TagsList from '../tags/TagsList';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import AsideMenu from './AsideMenu'; import './MenuLayout.scss';
export class MenuLayoutComponent extends React.Component { const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
static propTypes = { class MenuLayout extends React.Component {
match: PropTypes.object, static propTypes = {
selectServer: PropTypes.func, match: PropTypes.object,
location: PropTypes.object, selectServer: PropTypes.func,
selectedServer: serverType, location: PropTypes.object,
selectedServer: serverType,
};
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: 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}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
}; };
state = { showSideBar: false };
// FIXME Shouldn't use componentWillMount, but this code has to be run before children components are rendered
/* eslint react/no-deprecated: "off" */
componentWillMount() {
const { match, selectServer } = this.props;
const { params: { serverId } } = match;
selectServer(serverId);
}
componentDidUpdate(prevProps) {
const { location } = this.props;
// Hide sidebar when location changes
if (location !== prevProps.location) {
this.setState({ showSideBar: false });
}
}
render() {
const { selectedServer } = this.props;
const burgerClasses = classnames('menu-layout__burger-icon', {
'menu-layout__burger-icon--active': this.state.showSideBar,
});
return (
<React.Fragment>
<FontAwesomeIcon
icon={burgerIcon}
className={burgerClasses}
onClick={() => this.setState(({ showSideBar }) => ({ showSideBar: !showSideBar }))}
/>
<Swipeable
delta={40}
className="menu-layout__swipeable"
onSwipedLeft={() => this.setState({ showSideBar: false })}
onSwipedRight={() => this.setState({ showSideBar: 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={ShortUrlsVisits}
/>
<Route
exact
path="/server/:serverId/manage-tags"
component={TagsList}
/>
</Switch>
</div>
</div>
</Swipeable>
</React.Fragment>
);
}
}
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter
)(MenuLayoutComponent);
export default MenuLayout; export default MenuLayout;

View file

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
export class ScrollToTopComponent extends React.Component { export default class ScrollToTop extends React.Component {
static propTypes = { static propTypes = {
location: PropTypes.object, location: PropTypes.object,
window: PropTypes.shape({ window: PropTypes.shape({
@ -11,7 +10,7 @@ export class ScrollToTopComponent extends React.Component {
children: PropTypes.node, children: PropTypes.node,
}; };
static defaultProps = { static defaultProps = {
window, window: global.window,
}; };
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -26,7 +25,3 @@ export class ScrollToTopComponent extends React.Component {
return this.props.children; return this.props.children;
} }
} }
const ScrollToTop = withRouter(ScrollToTopComponent);
export default ScrollToTop;

View file

@ -0,0 +1,32 @@
import ScrollToTop from '../ScrollToTop';
import MainHeader from '../MainHeader';
import Home from '../Home';
import MenuLayout from '../MenuLayout';
import AsideMenu from '../AsideMenu';
const provideServices = (bottle, connect, withRouter) => {
bottle.constant('ScrollToTop', ScrollToTop);
bottle.decorator('ScrollToTop', withRouter);
bottle.serviceFactory('MainHeader', MainHeader, 'ServersDropdown');
bottle.decorator('MainHeader', withRouter);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
bottle.serviceFactory(
'MenuLayout',
MenuLayout,
'TagsList',
'ShortUrls',
'AsideMenu',
'CreateShortUrl',
'ShortUrlVisits'
);
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
bottle.decorator('MenuLayout', withRouter);
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
};
export default provideServices;

37
src/container/index.js Normal file
View file

@ -0,0 +1,37 @@
import Bottle from 'bottlejs';
import { withRouter } from 'react-router-dom';
import { connect as reduxConnect } from 'react-redux';
import { pick } from 'ramda';
import App from '../App';
import provideCommonServices from '../common/services/provideServices';
import provideShortUrlsServices from '../short-urls/services/provideServices';
import provideServersServices from '../servers/services/provideServices';
import provideVisitsServices from '../visits/services/provideServices';
import provideTagsServices from '../tags/services/provideServices';
import provideUtilsServices from '../utils/services/provideServices';
const bottle = new Bottle();
const { container } = bottle;
const mapActionService = (map, actionName) => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: (...args) => container[actionName](...args),
});
const connect = (propsFromState, actionServiceNames) =>
reduxConnect(
propsFromState ? pick(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {})
);
bottle.serviceFactory('App', App, 'MainHeader', 'Home', 'MenuLayout', 'CreateServer');
provideCommonServices(bottle, connect, withRouter);
provideShortUrlsServices(bottle, connect);
provideServersServices(bottle, connect, withRouter);
provideTagsServices(bottle, connect);
provideVisitsServices(bottle, connect);
provideUtilsServices(bottle);
export default container;

13
src/container/store.js Normal file
View file

@ -0,0 +1,13 @@
import ReduxThunk from 'redux-thunk';
import { applyMiddleware, compose, createStore } from 'redux';
import reducers from '../reducers';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
export default store;

View file

@ -3,23 +3,15 @@ import React from 'react';
import { render } from 'react-dom'; import { render } from 'react-dom';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
import { homepage } from '../package.json'; import { homepage } from '../package.json';
import App from './App';
import './index.scss';
import ScrollToTop from './common/ScrollToTop';
import reducers from './reducers';
import registerServiceWorker from './registerServiceWorker'; import registerServiceWorker from './registerServiceWorker';
import container from './container';
import store from './container/store';
import '../node_modules/react-datepicker/dist/react-datepicker.css'; import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss'; import './common/react-tagsinput.scss';
import './index.scss';
const composeEnhancers = process.env.NODE_ENV !== 'production' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ const { App, ScrollToTop } = container;
? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
: compose;
const store = createStore(reducers, composeEnhancers(
applyMiddleware(ReduxThunk)
));
render( render(
<Provider store={store}> <Provider store={store}>

View file

@ -1,17 +1,13 @@
import { assoc, dissoc, pick, pipe } from 'ramda'; import { assoc, dissoc, pipe } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { stateFlagTimeout } from '../utils/utils'; import { stateFlagTimeout } from '../utils/utils';
import { resetSelectedServer } from './reducers/selectedServer';
import { createServer } from './reducers/server';
import './CreateServer.scss'; import './CreateServer.scss';
import ImportServersBtn from './helpers/ImportServersBtn';
const SHOW_IMPORT_MSG_TIME = 4000; const SHOW_IMPORT_MSG_TIME = 4000;
export class CreateServerComponent extends React.Component { const CreateServer = (ImportServersBtn) => class CreateServer extends React.Component {
static propTypes = { static propTypes = {
createServer: PropTypes.func, createServer: PropTypes.func,
history: PropTypes.shape({ history: PropTypes.shape({
@ -91,11 +87,6 @@ export class CreateServerComponent extends React.Component {
</div> </div>
); );
} }
} };
const CreateServer = connect(
pick([ 'selectedServer' ]),
{ createServer, resetSelectedServer }
)(CreateServerComponent);
export default CreateServer; export default CreateServer;

View file

@ -2,10 +2,9 @@ import deleteIcon from '@fortawesome/fontawesome-free-solid/faMinusCircle';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DeleteServerModal from './DeleteServerModal';
import { serverType } from './prop-types'; import { serverType } from './prop-types';
export default class DeleteServerButton extends React.Component { const DeleteServerButton = (DeleteServerModal) => class DeleteServerButton extends React.Component {
static propTypes = { static propTypes = {
server: serverType, server: serverType,
className: PropTypes.string, className: PropTypes.string,
@ -36,4 +35,6 @@ export default class DeleteServerButton extends React.Component {
</React.Fragment> </React.Fragment>
); );
} }
} };
export default DeleteServerButton;

View file

@ -1,10 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { compose } from 'redux';
import { deleteServer } from './reducers/server';
import { serverType } from './prop-types'; import { serverType } from './prop-types';
const propTypes = { const propTypes = {
@ -17,7 +13,7 @@ const propTypes = {
}), }),
}; };
export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServer, history }) => { const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }) => {
const closeModal = () => { const closeModal = () => {
deleteServer(server); deleteServer(server);
toggle(); toggle();
@ -42,11 +38,6 @@ export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServe
); );
}; };
DeleteServerModalComponent.propTypes = propTypes; DeleteServerModal.propTypes = propTypes;
const DeleteServerModal = compose(
withRouter,
connect(null, { deleteServer })
)(DeleteServerModalComponent);
export default DeleteServerModal; export default DeleteServerModal;

View file

@ -1,30 +1,20 @@
import { isEmpty, pick, values } from 'ramda'; import { isEmpty, values } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap'; import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { selectServer } from '../servers/reducers/selectedServer';
import serversExporter from '../servers/services/ServersExporter';
import { listServers } from './reducers/server';
import { serverType } from './prop-types'; import { serverType } from './prop-types';
export class ServersDropdownComponent extends React.Component { const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static defaultProps = {
serversExporter,
};
static propTypes = { static propTypes = {
servers: PropTypes.object, servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
}),
selectedServer: serverType, selectedServer: serverType,
selectServer: PropTypes.func, selectServer: PropTypes.func,
listServers: PropTypes.func, listServers: PropTypes.func,
}; };
renderServers = () => { renderServers = () => {
const { servers, selectedServer, selectServer, serversExporter } = this.props; const { servers, selectedServer, selectServer } = this.props;
if (isEmpty(servers)) { if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>; return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@ -68,11 +58,6 @@ export class ServersDropdownComponent extends React.Component {
</UncontrolledDropdown> </UncontrolledDropdown>
); );
} }
} };
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }
)(ServersDropdownComponent);
export default ServersDropdown; export default ServersDropdown;

View file

@ -1,20 +1,15 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda'; import { assoc } from 'ramda';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createServers } from '../reducers/server';
import serversImporter, { serversImporterType } from '../services/ServersImporter';
export class ImportServersBtnComponent extends React.Component { const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
static defaultProps = { static defaultProps = {
serversImporter,
onImport: () => ({}), onImport: () => ({}),
}; };
static propTypes = { static propTypes = {
onImport: PropTypes.func, onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func, createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]), fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
}; };
@ -25,7 +20,8 @@ export class ImportServersBtnComponent extends React.Component {
} }
render() { render() {
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props; const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const onChange = (e) => const onChange = (e) =>
importServersFromFile(e.target.files[0]) importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server))) .then((servers) => servers.map((server) => assoc('id', uuid(), server)))
@ -56,8 +52,6 @@ export class ImportServersBtnComponent extends React.Component {
</React.Fragment> </React.Fragment>
); );
} }
} };
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
export default ImportServersBtn; export default ImportServersBtn;

View file

@ -1,6 +1,3 @@
import { curry } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import serversService from '../../servers/services/ServersService';
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams'; import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
@ -23,17 +20,13 @@ export default function reducer(state = defaultState, action) {
export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER }); export const resetSelectedServer = () => ({ type: RESET_SELECTED_SERVER });
export const _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => { export const selectServer = (serversService) => (serverId) => (dispatch) => {
dispatch(resetShortUrlParams()); dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId); const selectedServer = serversService.findServerById(serverId);
shlinkApiClient.setConfig(selectedServer);
dispatch({ dispatch({
type: SELECT_SERVER, type: SELECT_SERVER,
selectedServer, selectedServer,
}); });
}; };
export const selectServer = curry(_selectServer)(shlinkApiClient, serversService);

View file

@ -1,6 +1,3 @@
import { curry } from 'ramda';
import serversService from '../services/ServersService';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS'; export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
export default function reducer(state = {}, action) { export default function reducer(state = {}, action) {
@ -12,33 +9,25 @@ export default function reducer(state = {}, action) {
} }
} }
export const _listServers = (serversService) => ({ export const listServers = (serversService) => () => ({
type: FETCH_SERVERS, type: FETCH_SERVERS,
servers: serversService.listServers(), servers: serversService.listServers(),
}); });
export const listServers = () => _listServers(serversService); export const createServer = (serversService) => (server) => {
export const _createServer = (serversService, server) => {
serversService.createServer(server); serversService.createServer(server);
return _listServers(serversService); return listServers(serversService)();
}; };
export const createServer = curry(_createServer)(serversService); export const deleteServer = (serversService) => (server) => {
export const _deleteServer = (serversService, server) => {
serversService.deleteServer(server); serversService.deleteServer(server);
return _listServers(serversService); return listServers(serversService)();
}; };
export const deleteServer = curry(_deleteServer)(serversService); export const createServers = (serversService) => (servers) => {
export const _createServers = (serversService, servers) => {
serversService.createServers(servers); serversService.createServers(servers);
return _listServers(serversService); return listServers(serversService)();
}; };
export const createServers = curry(_createServers)(serversService);

View file

@ -1,6 +1,4 @@
import { dissoc, head, keys, values } from 'ramda'; import { dissoc, head, keys, values } from 'ramda';
import csvjson from 'csvjson';
import serversService from './ServersService';
const saveCsv = (window, csv) => { const saveCsv = (window, csv) => {
const { navigator, document } = window; const { navigator, document } = window;
@ -26,7 +24,7 @@ const saveCsv = (window, csv) => {
document.body.removeChild(link); document.body.removeChild(link);
}; };
export class ServersExporter { export default class ServersExporter {
constructor(serversService, window, csvjson) { constructor(serversService, window, csvjson) {
this.serversService = serversService; this.serversService = serversService;
this.window = window; this.window = window;
@ -49,7 +47,3 @@ export class ServersExporter {
} }
}; };
} }
const serverExporter = new ServersExporter(serversService, global.window, csvjson);
export default serverExporter;

View file

@ -1,11 +1,10 @@
import csvjson from 'csvjson';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
export const serversImporterType = PropTypes.shape({ export const serversImporterType = PropTypes.shape({
importServersFromFile: PropTypes.func, importServersFromFile: PropTypes.func,
}); });
export class ServersImporter { export default class ServersImporter {
constructor(csvjson) { constructor(csvjson) {
this.csvjson = csvjson; this.csvjson = csvjson;
} }
@ -28,7 +27,3 @@ export class ServersImporter {
}); });
}; };
} }
const serversImporter = new ServersImporter(csvjson);
export default serversImporter;

View file

@ -1,9 +1,8 @@
import { assoc, dissoc, reduce } from 'ramda'; import { assoc, dissoc, reduce } from 'ramda';
import storage from '../../utils/Storage';
const SERVERS_STORAGE_KEY = 'servers'; const SERVERS_STORAGE_KEY = 'servers';
export class ServersService { export default class ServersService {
constructor(storage) { constructor(storage) {
this.storage = storage; this.storage = storage;
} }
@ -30,7 +29,3 @@ export class ServersService {
dissoc(server.id, this.listServers()) dissoc(server.id, this.listServers())
); );
} }
const serversService = new ServersService(storage);
export default serversService;

View file

@ -0,0 +1,47 @@
import csvjson from 'csvjson';
import CreateServer from '../CreateServer';
import ServersDropdown from '../ServersDropdown';
import DeleteServerModal from '../DeleteServerModal';
import DeleteServerButton from '../DeleteServerButton';
import ImportServersBtn from '../helpers/ImportServersBtn';
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
import { createServer, createServers, deleteServer, listServers } from '../reducers/server';
import ServersImporter from './ServersImporter';
import ServersService from './ServersService';
import ServersExporter from './ServersExporter';
const provideServices = (bottle, connect, withRouter) => {
// Components
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn');
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ], [ 'listServers', 'selectServer' ]));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', withRouter);
bottle.decorator('DeleteServerModal', connect(null, [ 'deleteServer' ]));
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
// Services
bottle.constant('csvjson', csvjson);
bottle.constant('window', global.window);
bottle.service('ServersImporter', ServersImporter, 'csvjson');
bottle.service('ServersService', ServersService, 'Storage');
bottle.service('ServersExporter', ServersExporter, 'ServersService', 'window', 'csvjson');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'ServersService');
bottle.serviceFactory('createServer', createServer, 'ServersService');
bottle.serviceFactory('createServers', createServers, 'ServersService');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService');
bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};
export default provideServices;

View file

@ -1,20 +1,18 @@
import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown'; import downIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleDown';
import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp'; import upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { assoc, dissoc, isNil, pick, pipe, replace, trim } from 'ramda'; import { assoc, dissoc, isNil, pipe, replace, trim } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Collapse } from 'reactstrap'; import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import TagsSelector from '../tags/helpers/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult'; import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation'; import { createShortUrlResultType } from './reducers/shortUrlCreation';
const normalizeTag = pipe(trim, replace(/ /g, '-')); const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format(); const formatDate = (date) => isNil(date) ? date : date.format();
export class CreateShortUrlComponent extends React.Component { const CreateShortUrl = (TagsSelector) => class CreateShortUrl extends React.Component {
static propTypes = { static propTypes = {
createShortUrl: PropTypes.func, createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType, shortUrlCreationResult: createShortUrlResultType,
@ -122,11 +120,6 @@ export class CreateShortUrlComponent extends React.Component {
</div> </div>
); );
} }
} };
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
createShortUrl,
resetCreateShortUrl,
})(CreateShortUrlComponent);
export default CreateShortUrl; export default CreateShortUrl;

View file

@ -1,55 +1,56 @@
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags'; import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { isEmpty } from 'ramda';
import { isEmpty, pick } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Tag from '../tags/helpers/Tag';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList'; import Tag from '../tags/helpers/Tag';
import './SearchBar.scss';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
const propTypes = { const propTypes = {
listShortUrls: PropTypes.func, listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams: shortUrlsListParamsType,
}; };
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) { const SearchBar = (colorGenerator) => {
const selectedTags = shortUrlsListParams.tags || []; const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const selectedTags = shortUrlsListParams.tags || [];
return ( return (
<div className="serach-bar-container"> <div className="serach-bar-container">
<SearchField onChange={ <SearchField onChange={
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm }) (searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
} }
/> />
{!isEmpty(selectedTags) && ( {!isEmpty(selectedTags) && (
<h4 className="search-bar__selected-tag mt-2"> <h4 className="search-bar__selected-tag mt-2">
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" /> <FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
&nbsp; &nbsp;
{selectedTags.map((tag) => ( {selectedTags.map((tag) => (
<Tag <Tag
key={tag} colorGenerator={colorGenerator}
text={tag} key={tag}
clearable text={tag}
onClose={() => listShortUrls( clearable
{ onClose={() => listShortUrls(
...shortUrlsListParams, {
tags: selectedTags.filter((selectedTag) => selectedTag !== tag), ...shortUrlsListParams,
} tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
)} }
/> )}
))} />
</h4> ))}
)} </h4>
</div> )}
); </div>
} );
};
SearchBarComponent.propTypes = propTypes; SearchBar.propTypes = propTypes;
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent); return SearchBar;
};
export default SearchBar; export default SearchBar;

View file

@ -1,27 +1,21 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { assoc } from 'ramda';
import Paginator from './Paginator'; import Paginator from './Paginator';
import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList';
export function ShortUrlsComponent(props) { const ShortUrls = (SearchBar, ShortUrlsList) => (props) => {
const { match: { params } } = props; const { match: { params }, shortUrlsList } = props;
const { page, serverId } = params;
const { data = [], pagination } = shortUrlsList;
// Using a key on a component makes react to create a new instance every time the key changes // Using a key on a component makes react to create a new instance every time the key changes
const urlsListKey = `${params.serverId}_${params.page}`; const urlsListKey = `${serverId}_${page}`;
return ( return (
<div className="shlink-container"> <div className="shlink-container">
<div className="form-group"><SearchBar /></div> <div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} /> <ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} /> <Paginator paginator={pagination} serverId={serverId} />
</div> </div>
); );
} };
const ShortUrls = connect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)(ShortUrlsComponent);
export default ShortUrls; export default ShortUrls;

View file

@ -1,17 +1,15 @@
import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown'; import caretDownIcon from '@fortawesome/fontawesome-free-solid/faCaretDown';
import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp'; import caretUpIcon from '@fortawesome/fontawesome-free-solid/faCaretUp';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { head, isEmpty, keys, pick, values } from 'ramda'; import { head, isEmpty, keys, values } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import qs from 'qs'; import qs from 'qs';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { serverType } from '../servers/prop-types'; import { serverType } from '../servers/prop-types';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import { determineOrderDir } from '../utils/utils'; import { determineOrderDir } from '../utils/utils';
import { ShortUrlsRow } from './helpers/ShortUrlsRow'; import { shortUrlType } from './reducers/shortUrlsList';
import { listShortUrls, shortUrlType } from './reducers/shortUrlsList'; import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import { resetShortUrlParams, shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './ShortUrlsList.scss'; import './ShortUrlsList.scss';
const SORTABLE_FIELDS = { const SORTABLE_FIELDS = {
@ -21,7 +19,7 @@ const SORTABLE_FIELDS = {
visits: 'Visits', visits: 'Visits',
}; };
export class ShortUrlsListComponent extends React.Component { const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Component {
static propTypes = { static propTypes = {
listShortUrls: PropTypes.func, listShortUrls: PropTypes.func,
resetShortUrlParams: PropTypes.func, resetShortUrlParams: PropTypes.func,
@ -167,11 +165,6 @@ export class ShortUrlsListComponent extends React.Component {
</React.Fragment> </React.Fragment>
); );
} }
} };
const ShortUrlsList = connect(
pick([ 'selectedServer', 'shortUrlsListParams' ]),
{ listShortUrls, resetShortUrlParams }
)(ShortUrlsListComponent);
export default ShortUrlsList; export default ShortUrlsList;

View file

@ -1,18 +1,11 @@
import React, { Component } from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { pick, identity } from 'ramda'; import { identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
deleteShortUrl,
resetDeleteShortUrl,
shortUrlDeleted,
shortUrlDeletionType,
} from '../reducers/shortUrlDeletion';
import './QrCodeModal.scss';
export class DeleteShortUrlModalComponent extends Component { export default class DeleteShortUrlModal extends React.Component {
static propTypes = { static propTypes = {
shortUrl: shortUrlType, shortUrl: shortUrlType,
toggle: PropTypes.func, toggle: PropTypes.func,
@ -94,10 +87,3 @@ export class DeleteShortUrlModalComponent extends Component {
); );
} }
} }
const DeleteShortUrlModal = connect(
pick([ 'shortUrlDeletion' ]),
{ deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted }
)(DeleteShortUrlModalComponent);
export default DeleteShortUrlModal;

View file

@ -1,19 +1,11 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { pick } from 'ramda'; import { shortUrlTagsType } from '../reducers/shortUrlTags';
import TagsSelector from '../../tags/helpers/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
shortUrlTagsType,
shortUrlTagsEdited,
} from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink'; import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
export class EditTagsModalComponent extends React.Component { const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = { static propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired, toggle: PropTypes.func.isRequired,
@ -88,11 +80,6 @@ export class EditTagsModalComponent extends React.Component {
</Modal> </Modal>
); );
} }
} };
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
)(EditTagsModalComponent);
export default EditTagsModal; export default EditTagsModal;

View file

@ -2,16 +2,15 @@ import { isEmpty } from 'ramda';
import React from 'react'; import React from 'react';
import Moment from 'react-moment'; import Moment from 'react-moment';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Tag from '../../tags/helpers/Tag';
import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams'; import { shortUrlsListParamsType } from '../reducers/shortUrlsListParams';
import { serverType } from '../../servers/prop-types'; import { serverType } from '../../servers/prop-types';
import ExternalLink from '../../utils/ExternalLink'; import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import { stateFlagTimeout } from '../../utils/utils'; import { stateFlagTimeout } from '../../utils/utils';
import { ShortUrlsRowMenu } from './ShortUrlsRowMenu'; import Tag from '../../tags/helpers/Tag';
import './ShortUrlsRow.scss'; import './ShortUrlsRow.scss';
export class ShortUrlsRow extends React.Component { const ShortUrlsRow = (ShortUrlsRowMenu, colorGenerator) => class ShortUrlsRow extends React.Component {
static propTypes = { static propTypes = {
refreshList: PropTypes.func, refreshList: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType, shortUrlsListParams: shortUrlsListParamsType,
@ -31,6 +30,7 @@ export class ShortUrlsRow extends React.Component {
return tags.map((tag) => ( return tags.map((tag) => (
<Tag <Tag
colorGenerator={colorGenerator}
key={tag} key={tag}
text={tag} text={tag}
onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })} onClick={() => refreshList({ tags: [ ...selectedTags, tag ] })}
@ -72,4 +72,6 @@ export class ShortUrlsRow extends React.Component {
</tr> </tr>
); );
} }
} };
export default ShortUrlsRow;

View file

@ -15,11 +15,9 @@ import { serverType } from '../../servers/prop-types';
import { shortUrlType } from '../reducers/shortUrlsList'; import { shortUrlType } from '../reducers/shortUrlsList';
import PreviewModal from './PreviewModal'; import PreviewModal from './PreviewModal';
import QrCodeModal from './QrCodeModal'; import QrCodeModal from './QrCodeModal';
import EditTagsModal from './EditTagsModal';
import DeleteShortUrlModal from './DeleteShortUrlModal';
import './ShortUrlsRowMenu.scss'; import './ShortUrlsRowMenu.scss';
export class ShortUrlsRowMenu extends React.Component { const ShortUrlsRowMenu = (DeleteShortUrlModal, EditTagsModal) => class ShortUrlsRowMenu extends React.Component {
static propTypes = { static propTypes = {
completeShortUrl: PropTypes.string, completeShortUrl: PropTypes.string,
onCopyToClipboard: PropTypes.func, onCopyToClipboard: PropTypes.func,
@ -105,4 +103,6 @@ export class ShortUrlsRowMenu extends React.Component {
</ButtonDropdown> </ButtonDropdown>
); );
} }
} };
export default ShortUrlsRowMenu;

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START'; export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
@ -50,9 +48,12 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => { export const createShortUrl = (buildShlinkApiClient) => (data) => async (dispatch, getState) => {
dispatch({ type: CREATE_SHORT_URL_START }); dispatch({ type: CREATE_SHORT_URL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
const result = await shlinkApiClient.createShortUrl(data); const result = await shlinkApiClient.createShortUrl(data);
@ -62,6 +63,4 @@ export const _createShortUrl = (shlinkApiClient, data) => async (dispatch) => {
} }
}; };
export const createShortUrl = curry(_createShortUrl)(shlinkApiClient);
export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL }); export const resetCreateShortUrl = () => ({ type: RESET_CREATE_SHORT_URL });

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START'; const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
@ -56,9 +54,12 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch) => { export const deleteShortUrl = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: DELETE_SHORT_URL_START }); dispatch({ type: DELETE_SHORT_URL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
await shlinkApiClient.deleteShortUrl(shortCode); await shlinkApiClient.deleteShortUrl(shortCode);
dispatch({ type: DELETE_SHORT_URL, shortCode }); dispatch({ type: DELETE_SHORT_URL, shortCode });
@ -69,8 +70,6 @@ export const _deleteShortUrl = (shlinkApiClient, shortCode) => async (dispatch)
} }
}; };
export const deleteShortUrl = curry(_deleteShortUrl)(shlinkApiClient);
export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL }); export const resetDeleteShortUrl = () => ({ type: RESET_DELETE_SHORT_URL });
export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode }); export const shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View file

@ -1,6 +1,5 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient'; import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START'; export const EDIT_SHORT_URL_TAGS_START = 'shlink/shortUrlTags/EDIT_SHORT_URL_TAGS_START';
@ -40,8 +39,7 @@ export default function reducer(state = defaultState, action) {
}; };
case EDIT_SHORT_URL_TAGS: case EDIT_SHORT_URL_TAGS:
return { return {
shortCode: action.shortCode, ...pick([ 'shortCode', 'tags' ], action),
tags: action.tags,
saving: false, saving: false,
error: false, error: false,
}; };
@ -52,8 +50,10 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (dispatch) => { export const editShortUrlTags = (buildShlinkApiClient) => (shortCode, tags) => async (dispatch, getState) => {
dispatch({ type: EDIT_SHORT_URL_TAGS_START }); dispatch({ type: EDIT_SHORT_URL_TAGS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags); const normalizedTags = await shlinkApiClient.updateShortUrlTags(shortCode, tags);
@ -66,8 +66,6 @@ export const _editShortUrlTags = (shlinkApiClient, shortCode, tags) => async (di
} }
}; };
export const editShortUrlTags = curry(_editShortUrlTags)(shlinkApiClient);
export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS }); export const resetShortUrlsTags = () => ({ type: RESET_EDIT_SHORT_URL_TAGS });
export const shortUrlTagsEdited = (shortCode, tags) => ({ export const shortUrlTagsEdited = (shortCode, tags) => ({

View file

@ -1,6 +1,5 @@
import { assoc, assocPath, reject } from 'ramda'; import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags'; import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
import { SHORT_URL_DELETED } from './shortUrlDeletion'; import { SHORT_URL_DELETED } from './shortUrlDeletion';
@ -55,9 +54,12 @@ export default function reducer(state = initialState, action) {
} }
} }
export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch) => { export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
dispatch({ type: LIST_SHORT_URLS_START }); dispatch({ type: LIST_SHORT_URLS_START });
const { selectedServer = {} } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
const shortUrls = await shlinkApiClient.listShortUrls(params); const shortUrls = await shlinkApiClient.listShortUrls(params);
@ -66,5 +68,3 @@ export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch)
dispatch({ type: LIST_SHORT_URLS_ERROR, params }); dispatch({ type: LIST_SHORT_URLS_ERROR, params });
} }
}; };
export const listShortUrls = (params = {}) => _listShortUrls(shlinkApiClient, params);

View file

@ -0,0 +1,71 @@
import { connect as reduxConnect } from 'react-redux';
import { assoc } from 'ramda';
import ShortUrls from '../ShortUrls';
import SearchBar from '../SearchBar';
import ShortUrlsList from '../ShortUrlsList';
import ShortUrlsRow from '../helpers/ShortUrlsRow';
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
import CreateShortUrl from '../CreateShortUrl';
import DeleteShortUrlModal from '../helpers/DeleteShortUrlModal';
import EditTagsModal from '../helpers/EditTagsModal';
import { listShortUrls } from '../reducers/shortUrlsList';
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
import { deleteShortUrl, resetDeleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
import { editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited } from '../reducers/shortUrlTags';
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
bottle.decorator('ShortUrls', reduxConnect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
));
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
bottle.decorator('ShortUrlsList', connect(
[ 'selectedServer', 'shortUrlsListParams' ],
[ 'listShortUrls', 'resetShortUrlParams' ]
));
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator');
bottle.serviceFactory('ShortUrlsRowMenu', ShortUrlsRowMenu, 'DeleteShortUrlModal', 'EditTagsModal');
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'TagsSelector');
bottle.decorator(
'CreateShortUrl',
connect([ 'shortUrlCreationResult' ], [ 'createShortUrl', 'resetCreateShortUrl' ])
);
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
bottle.decorator('DeleteShortUrlModal', connect(
[ 'shortUrlDeletion' ],
[ 'deleteShortUrl', 'resetDeleteShortUrl', 'shortUrlDeleted' ]
));
bottle.serviceFactory('EditTagsModal', EditTagsModal, 'TagsSelector');
bottle.decorator('EditTagsModal', connect(
[ 'shortUrlTags' ],
[ 'editShortUrlTags', 'resetShortUrlsTags', 'shortUrlTagsEdited' ]
));
// Actions
bottle.serviceFactory('editShortUrlTags', editShortUrlTags, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlsTags', () => resetShortUrlsTags);
bottle.serviceFactory('shortUrlTagsEdited', () => shortUrlTagsEdited);
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
};
export default provideServices;

View file

@ -7,10 +7,8 @@ import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import TagBullet from './helpers/TagBullet'; import TagBullet from './helpers/TagBullet';
import './TagCard.scss'; import './TagCard.scss';
import DeleteTagConfirmModal from './helpers/DeleteTagConfirmModal';
import EditTagModal from './helpers/EditTagModal';
export default class TagCard extends React.Component { const TagCard = (DeleteTagConfirmModal, EditTagModal, colorGenerator) => class TagCard extends React.Component {
static propTypes = { static propTypes = {
tag: PropTypes.string, tag: PropTypes.string,
currentServerId: PropTypes.string, currentServerId: PropTypes.string,
@ -35,7 +33,7 @@ export default class TagCard extends React.Component {
<FontAwesomeIcon icon={editIcon} /> <FontAwesomeIcon icon={editIcon} />
</button> </button>
<h5 className="tag-card__tag-title"> <h5 className="tag-card__tag-title">
<TagBullet tag={tag} /> <TagBullet tag={tag} colorGenerator={colorGenerator} />
<Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link> <Link to={`/server/${currentServerId}/list-short-urls/1?tag=${tag}`}>{tag}</Link>
</h5> </h5>
</CardBody> </CardBody>
@ -45,4 +43,6 @@ export default class TagCard extends React.Component {
</Card> </Card>
); );
} }
} };
export default TagCard;

View file

@ -1,16 +1,13 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { splitEvery } from 'ramda';
import { pick, splitEvery } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import MuttedMessage from '../utils/MuttedMessage'; import MuttedMessage from '../utils/MuttedMessage';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { filterTags, forceListTags } from './reducers/tagsList';
import TagCard from './TagCard';
const { ceil } = Math; const { ceil } = Math;
const TAGS_GROUPS_AMOUNT = 4; const TAGS_GROUPS_AMOUNT = 4;
export class TagsListComponent extends React.Component { const TagsList = (TagCard) => class TagsList extends React.Component {
static propTypes = { static propTypes = {
filterTags: PropTypes.func, filterTags: PropTypes.func,
forceListTags: PropTypes.func, forceListTags: PropTypes.func,
@ -82,8 +79,6 @@ export class TagsListComponent extends React.Component {
</div> </div>
); );
} }
} };
const TagsList = connect(pick([ 'tagsList' ]), { forceListTags, filterTags })(TagsListComponent);
export default TagsList; export default TagsList;

View file

@ -1,11 +1,9 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { pick } from 'ramda'; import { tagDeleteType } from '../reducers/tagDelete';
import { deleteTag, tagDeleted, tagDeleteType } from '../reducers/tagDelete';
export class DeleteTagConfirmModalComponent extends React.Component { export default class DeleteTagConfirmModal extends React.Component {
static propTypes = { static propTypes = {
tag: PropTypes.string.isRequired, tag: PropTypes.string.isRequired,
toggle: PropTypes.func.isRequired, toggle: PropTypes.func.isRequired,
@ -67,10 +65,3 @@ export class DeleteTagConfirmModalComponent extends React.Component {
); );
} }
} }
const DeleteTagConfirmModal = connect(
pick([ 'tagDelete' ]),
{ deleteTag, tagDeleted }
)(DeleteTagConfirmModalComponent);
export default DeleteTagConfirmModal;

View file

@ -1,31 +1,23 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
import { pick } from 'ramda';
import { ChromePicker } from 'react-color'; import { ChromePicker } from 'react-color';
import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette'; import colorIcon from '@fortawesome/fontawesome-free-solid/faPalette';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import { editTag, tagEdited } from '../reducers/tagEdit';
import './EditTagModal.scss'; import './EditTagModal.scss';
export class EditTagModalComponent extends React.Component { const EditTagModal = ({ getColorForKey }) => class EditTagModal extends React.Component {
static propTypes = { static propTypes = {
tag: PropTypes.string, tag: PropTypes.string,
editTag: PropTypes.func, editTag: PropTypes.func,
toggle: PropTypes.func, toggle: PropTypes.func,
tagEdited: PropTypes.func, tagEdited: PropTypes.func,
colorGenerator: colorGeneratorType,
isOpen: PropTypes.bool, isOpen: PropTypes.bool,
tagEdit: PropTypes.shape({ tagEdit: PropTypes.shape({
error: PropTypes.bool, error: PropTypes.bool,
editing: PropTypes.bool, editing: PropTypes.bool,
}), }),
}; };
static defaultProps = {
colorGenerator,
};
saveTag = (e) => { saveTag = (e) => {
e.preventDefault(); e.preventDefault();
@ -53,12 +45,12 @@ export class EditTagModalComponent extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
const { colorGenerator, tag } = props; const { tag } = props;
this.state = { this.state = {
showColorPicker: false, showColorPicker: false,
tag, tag,
color: colorGenerator.getColorForKey(tag), color: getColorForKey(tag),
}; };
} }
@ -131,8 +123,6 @@ export class EditTagModalComponent extends React.Component {
</Modal> </Modal>
); );
} }
} };
const EditTagModal = connect(pick([ 'tagEdit' ]), { editTag, tagEdited })(EditTagModalComponent);
export default EditTagModal; export default EditTagModal;

View file

@ -1,41 +1,35 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './Tag.scss'; import './Tag.scss';
import { colorGeneratorType } from '../../utils/services/ColorGenerator';
const propTypes = { const propTypes = {
colorGenerator: colorGeneratorType,
text: PropTypes.string, text: PropTypes.string,
children: PropTypes.node, children: PropTypes.node,
clearable: PropTypes.bool, clearable: PropTypes.bool,
colorGenerator: colorGeneratorType,
onClick: PropTypes.func, onClick: PropTypes.func,
onClose: PropTypes.func, onClose: PropTypes.func,
}; };
const defaultProps = {
const Tag = ({
text,
children,
clearable,
colorGenerator, colorGenerator,
}; onClick = () => {},
onClose = () => {},
}) => (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span>
);
export default function Tag(
{
colorGenerator,
text,
children,
clearable,
onClick = () => ({}),
onClose = () => ({}),
}
) {
return (
<span
className="badge tag"
style={{ backgroundColor: colorGenerator.getColorForKey(text), cursor: clearable ? 'auto' : 'pointer' }}
onClick={onClick}
>
{children || text}
{clearable && <span className="close tag__close-selected-tag" onClick={onClose}>&times;</span>}
</span>
);
}
Tag.defaultProps = defaultProps;
Tag.propTypes = propTypes; Tag.propTypes = propTypes;
export default Tag;

View file

@ -1,24 +1,20 @@
import React from 'react'; import React from 'react';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator'; import { colorGeneratorType } from '../../utils/services/ColorGenerator';
import './TagBullet.scss'; import './TagBullet.scss';
const propTypes = { const propTypes = {
tag: PropTypes.string.isRequired, tag: PropTypes.string.isRequired,
colorGenerator: colorGeneratorType, colorGenerator: colorGeneratorType,
}; };
const defaultProps = {
colorGenerator,
};
export default function TagBullet({ tag, colorGenerator }) { const TagBullet = ({ tag, colorGenerator }) => (
return ( <div
<div style={{ backgroundColor: colorGenerator.getColorForKey(tag) }}
style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} className="tag-bullet"
className="tag-bullet" />
/> );
);
}
TagBullet.propTypes = propTypes; TagBullet.propTypes = propTypes;
TagBullet.defaultProps = defaultProps;
export default TagBullet;

View file

@ -1,26 +1,21 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import TagsInput from 'react-tagsinput'; import TagsInput from 'react-tagsinput';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Autosuggest from 'react-autosuggest'; import Autosuggest from 'react-autosuggest';
import { pick, identity } from 'ramda'; import { identity } from 'ramda';
import { listTags } from '../reducers/tagsList';
import colorGenerator, { colorGeneratorType } from '../../utils/ColorGenerator';
import './TagsSelector.scss';
import TagBullet from './TagBullet'; import TagBullet from './TagBullet';
import './TagsSelector.scss';
export class TagsSelectorComponent extends React.Component { const TagsSelector = (colorGenerator) => class TagsSelector extends React.Component {
static propTypes = { static propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired, tags: PropTypes.arrayOf(PropTypes.string).isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string, placeholder: PropTypes.string,
colorGenerator: colorGeneratorType,
tagsList: PropTypes.shape({ tagsList: PropTypes.shape({
tags: PropTypes.arrayOf(PropTypes.string), tags: PropTypes.arrayOf(PropTypes.string),
}), }),
}; };
static defaultProps = { static defaultProps = {
colorGenerator,
placeholder: 'Add tags to the URL', placeholder: 'Add tags to the URL',
}; };
@ -31,7 +26,7 @@ export class TagsSelectorComponent extends React.Component {
} }
render() { render() {
const { tags, onChange, placeholder, colorGenerator, tagsList } = this.props; const { tags, onChange, placeholder, tagsList } = this.props;
const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => ( const renderTag = ({ tag, key, disabled, onRemove, classNameRemove, getTagDisplayValue, ...other }) => (
<span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}> <span key={key} style={{ backgroundColor: colorGenerator.getColorForKey(tag) }} {...other}>
{getTagDisplayValue(tag)} {getTagDisplayValue(tag)}
@ -59,7 +54,7 @@ export class TagsSelectorComponent extends React.Component {
getSuggestionValue={(suggestion) => suggestion} getSuggestionValue={(suggestion) => suggestion}
renderSuggestion={(suggestion) => ( renderSuggestion={(suggestion) => (
<React.Fragment> <React.Fragment>
<TagBullet tag={suggestion} /> <TagBullet tag={suggestion} colorGenerator={colorGenerator} />
{suggestion} {suggestion}
</React.Fragment> </React.Fragment>
)} )}
@ -86,8 +81,6 @@ export class TagsSelectorComponent extends React.Component {
/> />
); );
} }
} };
const TagsSelector = connect(pick([ 'tagsList' ]), { listTags })(TagsSelectorComponent);
export default TagsSelector; export default TagsSelector;

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START'; export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
@ -41,9 +39,12 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => { export const deleteTag = (buildShlinkApiClient) => (tag) => async (dispatch, getState) => {
dispatch({ type: DELETE_TAG_START }); dispatch({ type: DELETE_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
await shlinkApiClient.deleteTags([ tag ]); await shlinkApiClient.deleteTags([ tag ]);
dispatch({ type: DELETE_TAG }); dispatch({ type: DELETE_TAG });
@ -54,6 +55,4 @@ export const _deleteTag = (shlinkApiClient, tag) => async (dispatch) => {
} }
}; };
export const deleteTag = curry(_deleteTag)(shlinkApiClient);
export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag }); export const tagDeleted = (tag) => ({ type: TAG_DELETED, tag });

View file

@ -1,6 +1,4 @@
import { curry, pick } from 'ramda'; import { pick } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START'; export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
@ -42,9 +40,15 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, color) => async (dispatch) => { export const editTag = (buildShlinkApiClient, colorGenerator) => (oldName, newName, color) => async (
dispatch,
getState
) => {
dispatch({ type: EDIT_TAG_START }); dispatch({ type: EDIT_TAG_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
await shlinkApiClient.editTag(oldName, newName); await shlinkApiClient.editTag(oldName, newName);
colorGenerator.setColorForKey(newName, color); colorGenerator.setColorForKey(newName, color);
@ -56,8 +60,6 @@ export const _editTag = (shlinkApiClient, colorGenerator, oldName, newName, colo
} }
}; };
export const editTag = curry(_editTag)(shlinkApiClient, colorGenerator);
export const tagEdited = (oldName, newName, color) => ({ export const tagEdited = (oldName, newName, color) => ({
type: TAG_EDITED, type: TAG_EDITED,
oldName, oldName,

View file

@ -1,5 +1,5 @@
import { isEmpty, reject } from 'ramda'; import { isEmpty, reject } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient'; import { buildShlinkApiClientWithAxios as buildShlinkApiClient } from '../../utils/services/ShlinkApiClientBuilder';
import { TAG_DELETED } from './tagDelete'; import { TAG_DELETED } from './tagDelete';
import { TAG_EDITED } from './tagEdit'; import { TAG_EDITED } from './tagEdit';
@ -66,8 +66,8 @@ export default function reducer(state = defaultState, action) {
} }
} }
export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => { export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList } = getState(); const { tagsList, selectedServer } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) { if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return; return;
@ -76,6 +76,7 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
dispatch({ type: LIST_TAGS_START }); dispatch({ type: LIST_TAGS_START });
try { try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags(); const tags = await shlinkApiClient.listTags();
dispatch({ tags, type: LIST_TAGS }); dispatch({ tags, type: LIST_TAGS });
@ -84,9 +85,9 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
} }
}; };
export const listTags = () => _listTags(shlinkApiClient); export const listTags = () => _listTags(buildShlinkApiClient);
export const forceListTags = () => _listTags(shlinkApiClient, true); export const forceListTags = () => _listTags(buildShlinkApiClient, true);
export const filterTags = (searchTerm) => ({ export const filterTags = (searchTerm) => ({
type: FILTER_TAGS, type: FILTER_TAGS,

View file

@ -0,0 +1,37 @@
import TagsSelector from '../helpers/TagsSelector';
import TagCard from '../TagCard';
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
import EditTagModal from '../helpers/EditTagModal';
import TagsList from '../TagsList';
import { filterTags, forceListTags, listTags } from '../reducers/tagsList';
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
import { editTag, tagEdited } from '../reducers/tagEdit';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
bottle.decorator('TagsList', connect([ 'tagsList' ], [ 'forceListTags', 'filterTags' ]));
// Actions
bottle.serviceFactory('filterTags', () => filterTags);
bottle.serviceFactory('forceListTags', () => forceListTags);
bottle.serviceFactory('listTags', () => listTags);
bottle.serviceFactory('tagDeleted', () => tagDeleted);
bottle.serviceFactory('tagEdited', () => tagEdited);
bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient');
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
};
export default provideServices;

View file

@ -1,6 +1,5 @@
import { range } from 'ramda'; import { range } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import storage from './Storage';
const HEX_COLOR_LENGTH = 6; const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math; const { floor, random } = Math;
@ -13,7 +12,7 @@ const buildRandomColor = () =>
}`; }`;
const normalizeKey = (key) => key.toLowerCase().trim(); const normalizeKey = (key) => key.toLowerCase().trim();
export class ColorGenerator { export default class ColorGenerator {
constructor(storage) { constructor(storage) {
this.storage = storage; this.storage = storage;
this.colors = this.storage.get('colors') || {}; this.colors = this.storage.get('colors') || {};
@ -45,7 +44,3 @@ export const colorGeneratorType = PropTypes.shape({
getColorForKey: PropTypes.func, getColorForKey: PropTypes.func,
setColorForKey: PropTypes.func, setColorForKey: PropTypes.func,
}); });
const colorGenerator = new ColorGenerator(storage);
export default colorGenerator;

View file

@ -1,26 +1,18 @@
import axios from 'axios';
import qs from 'qs'; import qs from 'qs';
import { isEmpty, isNil, reject } from 'ramda'; import { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1'; const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401; const STATUS_UNAUTHORIZED = 401;
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export class ShlinkApiClient { export default class ShlinkApiClient {
constructor(axios) { constructor(axios, baseUrl, apiKey) {
this.axios = axios; this.axios = axios;
this._baseUrl = ''; this._baseUrl = buildRestUrl(baseUrl);
this._apiKey = ''; this._apiKey = apiKey || '';
this._token = ''; this._token = '';
} }
/**
* Sets the base URL to be used on any request
*/
setConfig = ({ url, apiKey }) => {
this._baseUrl = `${url}/rest/v${API_VERSION}`;
this._apiKey = apiKey;
};
listShortUrls = (options = {}) => listShortUrls = (options = {}) =>
this._performRequest('/short-codes', 'GET', options) this._performRequest('/short-codes', 'GET', options)
.then((resp) => resp.data.shortUrls) .then((resp) => resp.data.shortUrls)
@ -113,7 +105,3 @@ export class ShlinkApiClient {
return Promise.reject(e); return Promise.reject(e);
}; };
} }
const shlinkApiClient = new ShlinkApiClient(axios);
export default shlinkApiClient;

View file

@ -0,0 +1,18 @@
import * as axios from 'axios';
import ShlinkApiClient from './ShlinkApiClient';
const apiClients = {};
const buildShlinkApiClient = (axios) => ({ url, apiKey }) => {
const clientKey = `${url}_${apiKey}`;
if (!apiClients[clientKey]) {
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
}
return apiClients[clientKey];
};
export default buildShlinkApiClient;
export const buildShlinkApiClientWithAxios = buildShlinkApiClient(axios);

View file

@ -1,7 +1,7 @@
const PREFIX = 'shlink'; const PREFIX = 'shlink';
const buildPath = (path) => `${PREFIX}.${path}`; const buildPath = (path) => `${PREFIX}.${path}`;
export class Storage { export default class Storage {
constructor(localStorage) { constructor(localStorage) {
this.localStorage = localStorage; this.localStorage = localStorage;
} }
@ -14,15 +14,3 @@ export class Storage {
set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value)); set = (key, value) => this.localStorage.setItem(buildPath(key), JSON.stringify(value));
} }
const browserStorage = global.localStorage || {
getItem() {
return '';
},
setItem() {
return '';
},
};
const storage = new Storage(browserStorage);
export default storage;

View file

@ -0,0 +1,15 @@
import axios from 'axios';
import Storage from './Storage';
import ColorGenerator from './ColorGenerator';
import buildShlinkApiClient from './ShlinkApiClientBuilder';
const provideServices = (bottle) => {
bottle.constant('localStorage', global.localStorage);
bottle.service('Storage', Storage, 'localStorage');
bottle.service('ColorGenerator', ColorGenerator, 'Storage');
bottle.constant('axios', axios);
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
};
export default provideServices;

View file

@ -1,31 +1,25 @@
import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch'; import preloader from '@fortawesome/fontawesome-free-solid/faCircleNotch';
import FontAwesomeIcon from '@fortawesome/react-fontawesome'; import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda'; import { isEmpty, mapObjIndexed } from 'ramda';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import DateInput from '../utils/DateInput'; import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage'; import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph'; import SortableBarGraph from './SortableBarGraph';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits'; import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import {
processBrowserStats,
processCountriesStats,
processOsStats,
processReferrersStats,
} from './services/VisitsParser';
import { VisitsHeader } from './VisitsHeader'; import { VisitsHeader } from './VisitsHeader';
import GraphCard from './GraphCard'; import GraphCard from './GraphCard';
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail'; import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss'; import './ShortUrlVisits.scss';
export class ShortUrlsVisitsComponent extends React.Component { const ShortUrlVisits = ({
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
}) => class ShortUrlVisits extends React.Component {
static propTypes = { static propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.shape({ match: PropTypes.shape({
params: PropTypes.object, params: PropTypes.object,
}), }),
@ -34,12 +28,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
getShortUrlDetail: PropTypes.func, getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType, shortUrlDetail: shortUrlDetailType,
}; };
static defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
state = { startDate: undefined, endDate: undefined }; state = { startDate: undefined, endDate: undefined };
loadVisits = () => { loadVisits = () => {
@ -59,14 +47,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
} }
render() { render() {
const { const { shortUrlVisits, shortUrlDetail } = this.props;
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits,
shortUrlDetail,
} = this.props;
const renderVisitsContent = () => { const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits; const { visits, loading, error } = shortUrlVisits;
@ -153,11 +134,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
</div> </div>
); );
} }
} };
const ShortUrlsVisits = connect( export default ShortUrlVisits;
pick([ 'shortUrlVisits', 'shortUrlDetail' ]),
{ getShortUrlVisits, getShortUrlDetail }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList'; import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
@ -45,9 +43,12 @@ export default function reducer(state = initialState, action) {
} }
} }
export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatch) => { export const getShortUrlDetail = (buildShlinkApiClient) => (shortCode) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_DETAIL_START }); dispatch({ type: GET_SHORT_URL_DETAIL_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
const shortUrl = await shlinkApiClient.getShortUrl(shortCode); const shortUrl = await shlinkApiClient.getShortUrl(shortCode);
@ -56,5 +57,3 @@ export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatc
dispatch({ type: GET_SHORT_URL_DETAIL_ERROR }); dispatch({ type: GET_SHORT_URL_DETAIL_ERROR });
} }
}; };
export const getShortUrlDetail = curry(_getShortUrlDetail)(shlinkApiClient);

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */ /* eslint-disable padding-line-between-statements, newline-after-var */
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
@ -44,9 +42,12 @@ export default function reducer(state = initialState, action) {
} }
} }
export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (dispatch) => { export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, dates) => async (dispatch, getState) => {
dispatch({ type: GET_SHORT_URL_VISITS_START }); dispatch({ type: GET_SHORT_URL_VISITS_START });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try { try {
const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates); const visits = await shlinkApiClient.getShortUrlVisits(shortCode, dates);
@ -55,5 +56,3 @@ export const _getShortUrlVisits = (shlinkApiClient, shortCode, dates) => async (
dispatch({ type: GET_SHORT_URL_VISITS_ERROR }); dispatch({ type: GET_SHORT_URL_VISITS_ERROR });
} }
}; };
export const getShortUrlVisits = curry(_getShortUrlVisits)(shlinkApiClient);

View file

@ -0,0 +1,22 @@
import ShortUrlVisits from '../ShortUrlVisits';
import { getShortUrlVisits } from '../reducers/shortUrlVisits';
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
import * as visitsParser from './VisitsParser';
const provideServices = (bottle, connect) => {
// Components
bottle.serviceFactory('ShortUrlVisits', ShortUrlVisits, 'VisitsParser');
bottle.decorator('ShortUrlVisits', connect(
[ 'shortUrlVisits', 'shortUrlDetail' ],
[ 'getShortUrlVisits', 'getShortUrlDetail' ]
));
// Services
bottle.serviceFactory('VisitsParser', () => visitsParser);
// Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
};
export default provideServices;

View file

@ -1,13 +1,16 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Route } from 'react-router-dom'; import { Route } from 'react-router-dom';
import App from '../src/App'; import { identity } from 'ramda';
import MainHeader from '../src/common/MainHeader'; import appFactory from '../src/App';
describe('<App />', () => { describe('<App />', () => {
let wrapper; let wrapper;
const MainHeader = () => '';
beforeEach(() => { beforeEach(() => {
const App = appFactory(MainHeader, identity, identity, identity);
wrapper = shallow(<App />); wrapper = shallow(<App />);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -1,12 +1,15 @@
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import AsideMenu from '../../src/common/AsideMenu'; import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => { describe('<AsideMenu />', () => {
let wrapped; let wrapped;
const DeleteServerButton = () => '';
beforeEach(() => { beforeEach(() => {
const AsideMenu = asideMenuCreator(DeleteServerButton);
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />); wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
}); });
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
@ -20,6 +23,6 @@ describe('<AsideMenu />', () => {
}); });
it('contains a button to delete server', () => { it('contains a button to delete server', () => {
expect(wrapped.find('DeleteServerButton')).toHaveLength(1); expect(wrapped.find(DeleteServerButton)).toHaveLength(1);
}); });
}); });

View file

@ -2,7 +2,7 @@ import { shallow } from 'enzyme';
import { values } from 'ramda'; import { values } from 'ramda';
import React from 'react'; import React from 'react';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { HomeComponent } from '../../src/common/Home'; import Home from '../../src/common/Home';
describe('<Home />', () => { describe('<Home />', () => {
let wrapped; let wrapped;
@ -15,7 +15,7 @@ describe('<Home />', () => {
const createComponent = (props) => { const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props }; const actualProps = { ...defaultProps, ...props };
wrapped = shallow(<HomeComponent {...actualProps} />); wrapped = shallow(<Home {...actualProps} />);
return wrapped; return wrapped;
}; };

View file

@ -2,11 +2,11 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { identity } from 'ramda'; import { identity } from 'ramda';
import sinon from 'sinon'; import sinon from 'sinon';
import { CreateServerComponent } from '../../src/servers/CreateServer'; import createServerConstruct from '../../src/servers/CreateServer';
import ImportServersBtn from '../../src/servers/helpers/ImportServersBtn';
describe('<CreateServer />', () => { describe('<CreateServer />', () => {
let wrapper; let wrapper;
const ImportServersBtn = () => '';
const createServerMock = sinon.fake(); const createServerMock = sinon.fake();
const historyMock = { const historyMock = {
push: sinon.fake(), push: sinon.fake(),
@ -16,12 +16,10 @@ describe('<CreateServer />', () => {
createServerMock.resetHistory(); createServerMock.resetHistory();
historyMock.push.resetHistory(); historyMock.push.resetHistory();
const CreateServer = createServerConstruct(ImportServersBtn);
wrapper = shallow( wrapper = shallow(
<CreateServerComponent <CreateServer createServer={createServerMock} resetSelectedServer={identity} history={historyMock} />
createServer={createServerMock}
resetSelectedServer={identity}
history={historyMock}
/>
); );
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -1,12 +1,14 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import DeleteServerButton from '../../src/servers/DeleteServerButton'; import deleteServerButtonConstruct from '../../src/servers/DeleteServerButton';
import DeleteServerModal from '../../src/servers/DeleteServerModal'; import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerButton />', () => { describe('<DeleteServerButton />', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
const DeleteServerButton = deleteServerButtonConstruct(DeleteServerModal);
wrapper = shallow(<DeleteServerButton server={{}} className="button" />); wrapper = shallow(<DeleteServerButton server={{}} className="button" />);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import sinon from 'sinon'; import sinon from 'sinon';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { DeleteServerModalComponent } from '../../src/servers/DeleteServerModal'; import DeleteServerModal from '../../src/servers/DeleteServerModal';
describe('<DeleteServerModal />', () => { describe('<DeleteServerModal />', () => {
let wrapper; let wrapper;
@ -17,7 +17,7 @@ describe('<DeleteServerModal />', () => {
historyMock.push.resetHistory(); historyMock.push.resetHistory();
wrapper = shallow( wrapper = shallow(
<DeleteServerModalComponent <DeleteServerModal
server={{ name: serverName }} server={{ name: serverName }}
toggle={toggleMock} toggle={toggleMock}
isOpen={true} isOpen={true}

View file

@ -2,10 +2,11 @@ import { identity, values } from 'ramda';
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { DropdownItem, DropdownToggle } from 'reactstrap'; import { DropdownItem, DropdownToggle } from 'reactstrap';
import { ServersDropdownComponent } from '../../src/servers/ServersDropdown'; import serversDropdownCreator from '../../src/servers/ServersDropdown';
describe('<ServersDropdown />', () => { describe('<ServersDropdown />', () => {
let wrapped; let wrapped;
let ServersDropdown;
const servers = { const servers = {
'1a': { name: 'foo', id: 1 }, '1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 }, '2b': { name: 'bar', id: 2 },
@ -13,7 +14,8 @@ describe('<ServersDropdown />', () => {
}; };
beforeEach(() => { beforeEach(() => {
wrapped = shallow(<ServersDropdownComponent servers={servers} listServers={identity} />); ServersDropdown = serversDropdownCreator({});
wrapped = shallow(<ServersDropdown servers={servers} listServers={identity} />);
}); });
afterEach(() => wrapped.unmount()); afterEach(() => wrapped.unmount());
@ -31,7 +33,7 @@ describe('<ServersDropdown />', () => {
}); });
it('contains a message when no servers exist yet', () => { it('contains a message when no servers exist yet', () => {
wrapped = shallow(<ServersDropdownComponent servers={{}} listServers={identity} />); wrapped = shallow(<ServersDropdown servers={{}} listServers={identity} />);
const item = wrapped.find(DropdownItem); const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1); expect(item).toHaveLength(1);

View file

@ -2,7 +2,7 @@ import React from 'react';
import sinon from 'sinon'; import sinon from 'sinon';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { UncontrolledTooltip } from 'reactstrap'; import { UncontrolledTooltip } from 'reactstrap';
import { ImportServersBtnComponent } from '../../../src/servers/helpers/ImportServersBtn'; import importServersBtnConstruct from '../../../src/servers/helpers/ImportServersBtn';
describe('<ImportServersBtn />', () => { describe('<ImportServersBtn />', () => {
let wrapper; let wrapper;
@ -21,13 +21,10 @@ describe('<ImportServersBtn />', () => {
serversImporterMock.importServersFromFile.resetHistory(); serversImporterMock.importServersFromFile.resetHistory();
fileRef.current.click.resetHistory(); fileRef.current.click.resetHistory();
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
wrapper = shallow( wrapper = shallow(
<ImportServersBtnComponent <ImportServersBtn createServers={createServersMock} fileRef={fileRef} onImport={onImportMock} />
createServers={createServersMock}
serversImporter={serversImporterMock}
fileRef={fileRef}
onImport={onImportMock}
/>
); );
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -1,8 +1,8 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import reducer, { import reducer, {
_selectServer, selectServer,
RESET_SELECTED_SERVER,
resetSelectedServer, resetSelectedServer,
RESET_SELECTED_SERVER,
SELECT_SERVER, SELECT_SERVER,
} from '../../../src/servers/reducers/selectedServer'; } from '../../../src/servers/reducers/selectedServer';
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams'; import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
@ -29,9 +29,6 @@ describe('selectedServerReducer', () => {
}); });
describe('selectServer', () => { describe('selectServer', () => {
const ShlinkApiClientMock = {
setConfig: sinon.spy(),
};
const serverId = 'abc123'; const serverId = 'abc123';
const selectedServer = { const selectedServer = {
id: serverId, id: serverId,
@ -41,7 +38,6 @@ describe('selectedServerReducer', () => {
}; };
afterEach(() => { afterEach(() => {
ShlinkApiClientMock.setConfig.resetHistory();
ServersServiceMock.findServerById.resetHistory(); ServersServiceMock.findServerById.resetHistory();
}); });
@ -49,7 +45,7 @@ describe('selectedServerReducer', () => {
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
_selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(dispatch); selectServer(ServersServiceMock)(serverId)(dispatch);
expect(dispatch.callCount).toEqual(expectedDispatchCalls); expect(dispatch.callCount).toEqual(expectedDispatchCalls);
expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true); expect(dispatch.firstCall.calledWith({ type: RESET_SHORT_URL_PARAMS })).toEqual(true);
@ -60,9 +56,8 @@ describe('selectedServerReducer', () => {
}); });
it('invokes dependencies', () => { it('invokes dependencies', () => {
_selectServer(ShlinkApiClientMock, ServersServiceMock, serverId)(() => {}); selectServer(ServersServiceMock)(serverId)(() => {});
expect(ShlinkApiClientMock.setConfig.callCount).toEqual(1);
expect(ServersServiceMock.findServerById.callCount).toEqual(1); expect(ServersServiceMock.findServerById.callCount).toEqual(1);
}); });
}); });

View file

@ -1,10 +1,10 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { values } from 'ramda'; import { values } from 'ramda';
import reducer, { import reducer, {
_createServer, createServer,
_deleteServer, deleteServer,
_listServers, listServers,
_createServers, createServers,
FETCH_SERVERS, FETCH_SERVERS,
} from '../../../src/servers/reducers/server'; } from '../../../src/servers/reducers/server';
@ -38,7 +38,7 @@ describe('serverReducer', () => {
describe('listServers', () => { describe('listServers', () => {
it('fetches servers and returns them as part of the action', () => { it('fetches servers and returns them as part of the action', () => {
const result = _listServers(ServersServiceMock); const result = listServers(ServersServiceMock)();
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);
@ -51,7 +51,7 @@ describe('serverReducer', () => {
describe('createServer', () => { describe('createServer', () => {
it('adds new server and then fetches servers again', () => { it('adds new server and then fetches servers again', () => {
const serverToCreate = { id: 'abc123' }; const serverToCreate = { id: 'abc123' };
const result = _createServer(ServersServiceMock, serverToCreate); const result = createServer(ServersServiceMock)(serverToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);
@ -65,7 +65,7 @@ describe('serverReducer', () => {
describe('deleteServer', () => { describe('deleteServer', () => {
it('deletes a server and then fetches servers again', () => { it('deletes a server and then fetches servers again', () => {
const serverToDelete = { id: 'abc123' }; const serverToDelete = { id: 'abc123' };
const result = _deleteServer(ServersServiceMock, serverToDelete); const result = deleteServer(ServersServiceMock)(serverToDelete);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);
@ -79,7 +79,7 @@ describe('serverReducer', () => {
describe('createServer', () => { describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => { it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(servers); const serversToCreate = values(servers);
const result = _createServers(ServersServiceMock, serversToCreate); const result = createServers(ServersServiceMock)(serversToCreate);
expect(result).toEqual({ type: FETCH_SERVERS, servers }); expect(result).toEqual({ type: FETCH_SERVERS, servers });
expect(ServersServiceMock.listServers.callCount).toEqual(1); expect(ServersServiceMock.listServers.callCount).toEqual(1);

View file

@ -1,5 +1,5 @@
import sinon from 'sinon'; import sinon from 'sinon';
import { ServersExporter } from '../../../src/servers/services/ServersExporter'; import ServersExporter from '../../../src/servers/services/ServersExporter';
describe('ServersExporter', () => { describe('ServersExporter', () => {
const createLinkMock = () => ({ const createLinkMock = () => ({

View file

@ -1,5 +1,5 @@
import sinon from 'sinon'; import sinon from 'sinon';
import { ServersImporter } from '../../../src/servers/services/ServersImporter'; import ServersImporter from '../../../src/servers/services/ServersImporter';
describe('ServersImporter', () => { describe('ServersImporter', () => {
const servers = [{ name: 'foo' }, { name: 'bar' }]; const servers = [{ name: 'foo' }, { name: 'bar' }];

View file

@ -1,6 +1,6 @@
import sinon from 'sinon'; import sinon from 'sinon';
import { last } from 'ramda'; import { last } from 'ramda';
import { ServersService } from '../../../src/servers/services/ServersService'; import ServersService from '../../../src/servers/services/ServersService';
describe('ServersService', () => { describe('ServersService', () => {
const servers = { const servers = {

View file

@ -3,18 +3,20 @@ import { shallow } from 'enzyme';
import moment from 'moment'; import moment from 'moment';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { identity } from 'ramda'; import { identity } from 'ramda';
import { CreateShortUrlComponent as CreateShortUrl } from '../../src/short-urls/CreateShortUrl'; import createShortUrlsCreator from '../../src/short-urls/CreateShortUrl';
import TagsSelector from '../../src/tags/helpers/TagsSelector';
import DateInput from '../../src/utils/DateInput'; import DateInput from '../../src/utils/DateInput';
describe('<CreateShortUrl />', () => { describe('<CreateShortUrl />', () => {
let wrapper; let wrapper;
const TagsSelector = () => '';
const shortUrlCreationResult = { const shortUrlCreationResult = {
loading: false, loading: false,
}; };
const createShortUrl = sinon.spy(); const createShortUrl = sinon.spy();
beforeEach(() => { beforeEach(() => {
const CreateShortUrl = createShortUrlsCreator(TagsSelector);
wrapper = shallow( wrapper = shallow(
<CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} /> <CreateShortUrl shortUrlCreationResult={shortUrlCreationResult} createShortUrl={createShortUrl} />
); );

View file

@ -1,13 +1,14 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import sinon from 'sinon'; import sinon from 'sinon';
import { SearchBarComponent } from '../../src/short-urls/SearchBar'; import searchBarCreator from '../../src/short-urls/SearchBar';
import SearchField from '../../src/utils/SearchField'; import SearchField from '../../src/utils/SearchField';
import Tag from '../../src/tags/helpers/Tag'; import Tag from '../../src/tags/helpers/Tag';
describe('<SearchBar />', () => { describe('<SearchBar />', () => {
let wrapper; let wrapper;
const listShortUrlsMock = sinon.spy(); const listShortUrlsMock = sinon.spy();
const SearchBar = searchBarCreator({});
afterEach(() => { afterEach(() => {
listShortUrlsMock.resetHistory(); listShortUrlsMock.resetHistory();
@ -18,13 +19,13 @@ describe('<SearchBar />', () => {
}); });
it('renders a SearchField', () => { it('renders a SearchField', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />); wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(SearchField)).toHaveLength(1); expect(wrapper.find(SearchField)).toHaveLength(1);
}); });
it('renders no tags when the list of tags is empty', () => { it('renders no tags when the list of tags is empty', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} />); wrapper = shallow(<SearchBar shortUrlsListParams={{}} />);
expect(wrapper.find(Tag)).toHaveLength(0); expect(wrapper.find(Tag)).toHaveLength(0);
}); });
@ -32,13 +33,13 @@ describe('<SearchBar />', () => {
it('renders the proper amount of tags', () => { it('renders the proper amount of tags', () => {
const tags = [ 'foo', 'bar', 'baz' ]; const tags = [ 'foo', 'bar', 'baz' ];
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{ tags }} />); wrapper = shallow(<SearchBar shortUrlsListParams={{ tags }} />);
expect(wrapper.find(Tag)).toHaveLength(tags.length); expect(wrapper.find(Tag)).toHaveLength(tags.length);
}); });
it('updates short URLs list when search field changes', () => { it('updates short URLs list when search field changes', () => {
wrapper = shallow(<SearchBarComponent shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />); wrapper = shallow(<SearchBar shortUrlsListParams={{}} listShortUrls={listShortUrlsMock} />);
const searchField = wrapper.find(SearchField); const searchField = wrapper.find(SearchField);
expect(listShortUrlsMock.callCount).toEqual(0); expect(listShortUrlsMock.callCount).toEqual(0);
@ -48,7 +49,7 @@ describe('<SearchBar />', () => {
it('updates short URLs list when a tag is removed', () => { it('updates short URLs list when a tag is removed', () => {
wrapper = shallow( wrapper = shallow(
<SearchBarComponent shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} /> <SearchBar shortUrlsListParams={{ tags: [ 'foo' ] }} listShortUrls={listShortUrlsMock} />
); );
const tag = wrapper.find(Tag).first(); const tag = wrapper.find(Tag).first();

View file

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { ShortUrlsComponent as ShortUrls } from '../../src/short-urls/ShortUrls'; import shortUrlsCreator from '../../src/short-urls/ShortUrls';
import Paginator from '../../src/short-urls/Paginator'; import Paginator from '../../src/short-urls/Paginator';
import ShortUrlsList from '../../src/short-urls/ShortUrlsList';
import SearchBar from '../../src/short-urls/SearchBar';
describe('<ShortUrlsList />', () => { describe('<ShortUrls />', () => {
let wrapper; let wrapper;
const SearchBar = () => '';
const ShortUrlsList = () => '';
beforeEach(() => { beforeEach(() => {
const params = { const params = {
@ -14,6 +14,8 @@ describe('<ShortUrlsList />', () => {
page: '1', page: '1',
}; };
const ShortUrls = shortUrlsCreator(SearchBar, ShortUrlsList);
wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />); wrapper = shallow(<ShortUrls match={{ params }} shortUrlsList={{ data: [] }} />);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { identity } from 'ramda'; import { identity } from 'ramda';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { DeleteShortUrlModalComponent as DeleteShortUrlModal } from '../../../src/short-urls/helpers/DeleteShortUrlModal'; import DeleteShortUrlModal from '../../../src/short-urls/helpers/DeleteShortUrlModal';
describe('<DeleteShortUrlModal />', () => { describe('<DeleteShortUrlModal />', () => {
let wrapper; let wrapper;

View file

@ -4,7 +4,7 @@ import reducer, {
CREATE_SHORT_URL_ERROR, CREATE_SHORT_URL_ERROR,
CREATE_SHORT_URL, CREATE_SHORT_URL,
RESET_CREATE_SHORT_URL, RESET_CREATE_SHORT_URL,
_createShortUrl, createShortUrl,
resetCreateShortUrl, resetCreateShortUrl,
} from '../../../src/short-urls/reducers/shortUrlCreation'; } from '../../../src/short-urls/reducers/shortUrlCreation';
@ -54,6 +54,7 @@ describe('shortUrlCreationReducer', () => {
createShortUrl: sinon.fake.returns(result), createShortUrl: sinon.fake.returns(result),
}); });
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const getState = () => ({});
afterEach(() => dispatch.resetHistory()); afterEach(() => dispatch.resetHistory());
@ -61,9 +62,9 @@ describe('shortUrlCreationReducer', () => {
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
const result = 'foo'; const result = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve(result)); const apiClientMock = createApiClientMock(Promise.resolve(result));
const dispatchable = _createShortUrl(apiClientMock, {}); const dispatchable = createShortUrl(() => apiClientMock)({});
await dispatchable(dispatch); await dispatchable(dispatch, getState);
expect(apiClientMock.createShortUrl.callCount).toEqual(1); expect(apiClientMock.createShortUrl.callCount).toEqual(1);
@ -76,10 +77,10 @@ describe('shortUrlCreationReducer', () => {
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
const error = 'Error'; const error = 'Error';
const apiClientMock = createApiClientMock(Promise.reject(error)); const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _createShortUrl(apiClientMock, {}); const dispatchable = createShortUrl(() => apiClientMock)({});
try { try {
await dispatchable(dispatch); await dispatchable(dispatch, getState);
} catch (e) { } catch (e) {
expect(e).toEqual(error); expect(e).toEqual(error);
} }

View file

@ -1,13 +1,15 @@
import React from 'react'; import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import TagCard from '../../src/tags/TagCard'; import createTagCard from '../../src/tags/TagCard';
import TagBullet from '../../src/tags/helpers/TagBullet'; import TagBullet from '../../src/tags/helpers/TagBullet';
describe('<TagCard />', () => { describe('<TagCard />', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
const TagCard = createTagCard(() => '', () => '', {});
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />); wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
}); });
afterEach(() => wrapper.unmount()); afterEach(() => wrapper.unmount());

View file

@ -2,16 +2,17 @@ import React from 'react';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { identity, range } from 'ramda'; import { identity, range } from 'ramda';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { TagsListComponent as TagsList } from '../../src/tags/TagsList'; import createTagsList from '../../src/tags/TagsList';
import MuttedMessage from '../../src/utils/MuttedMessage'; import MuttedMessage from '../../src/utils/MuttedMessage';
import TagCard from '../../src/tags/TagCard';
import SearchField from '../../src/utils/SearchField'; import SearchField from '../../src/utils/SearchField';
describe('<TagsList />', () => { describe('<TagsList />', () => {
let wrapper; let wrapper;
const filterTags = sinon.spy(); const filterTags = sinon.spy();
const TagCard = () => '';
const createWrapper = (tagsList) => { const createWrapper = (tagsList) => {
const params = { serverId: '1' }; const params = { serverId: '1' };
const TagsList = createTagsList(TagCard);
wrapper = shallow( wrapper = shallow(
<TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} /> <TagsList forceListTags={identity} filterTags={filterTags} match={{ params }} tagsList={tagsList} />

View file

@ -5,7 +5,7 @@ import reducer, {
DELETE_TAG, DELETE_TAG,
TAG_DELETED, TAG_DELETED,
tagDeleted, tagDeleted,
_deleteTag, deleteTag,
} from '../../../src/tags/reducers/tagDelete'; } from '../../../src/tags/reducers/tagDelete';
describe('tagDeleteReducer', () => { describe('tagDeleteReducer', () => {
@ -48,6 +48,7 @@ describe('tagDeleteReducer', () => {
deleteTags: sinon.fake.returns(result), deleteTags: sinon.fake.returns(result),
}); });
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const getState = () => ({});
afterEach(() => dispatch.resetHistory()); afterEach(() => dispatch.resetHistory());
@ -55,9 +56,9 @@ describe('tagDeleteReducer', () => {
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
const tag = 'foo'; const tag = 'foo';
const apiClientMock = createApiClientMock(Promise.resolve()); const apiClientMock = createApiClientMock(Promise.resolve());
const dispatchable = _deleteTag(apiClientMock, tag); const dispatchable = deleteTag(() => apiClientMock)(tag);
await dispatchable(dispatch); await dispatchable(dispatch, getState);
expect(apiClientMock.deleteTags.callCount).toEqual(1); expect(apiClientMock.deleteTags.callCount).toEqual(1);
expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]); expect(apiClientMock.deleteTags.getCall(0).args).toEqual([[ tag ]]);
@ -72,10 +73,10 @@ describe('tagDeleteReducer', () => {
const error = 'Error'; const error = 'Error';
const tag = 'foo'; const tag = 'foo';
const apiClientMock = createApiClientMock(Promise.reject(error)); const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _deleteTag(apiClientMock, tag); const dispatchable = deleteTag(() => apiClientMock)(tag);
try { try {
await dispatchable(dispatch); await dispatchable(dispatch, getState);
} catch (e) { } catch (e) {
expect(e).toEqual(error); expect(e).toEqual(error);
} }

View file

@ -5,7 +5,7 @@ import reducer, {
EDIT_TAG, EDIT_TAG,
TAG_EDITED, TAG_EDITED,
tagEdited, tagEdited,
_editTag, editTag,
} from '../../../src/tags/reducers/tagEdit'; } from '../../../src/tags/reducers/tagEdit';
describe('tagEditReducer', () => { describe('tagEditReducer', () => {
@ -55,6 +55,7 @@ describe('tagEditReducer', () => {
setColorForKey: sinon.spy(), setColorForKey: sinon.spy(),
}; };
const dispatch = sinon.spy(); const dispatch = sinon.spy();
const getState = () => ({});
afterEach(() => { afterEach(() => {
colorGenerator.setColorForKey.resetHistory(); colorGenerator.setColorForKey.resetHistory();
@ -67,9 +68,9 @@ describe('tagEditReducer', () => {
const newName = 'bar'; const newName = 'bar';
const color = '#ff0000'; const color = '#ff0000';
const apiClientMock = createApiClientMock(Promise.resolve()); const apiClientMock = createApiClientMock(Promise.resolve());
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color); const dispatchable = editTag(() => apiClientMock, colorGenerator)(oldName, newName, color);
await dispatchable(dispatch); await dispatchable(dispatch, getState);
expect(apiClientMock.editTag.callCount).toEqual(1); expect(apiClientMock.editTag.callCount).toEqual(1);
expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]); expect(apiClientMock.editTag.getCall(0).args).toEqual([ oldName, newName ]);
@ -89,10 +90,10 @@ describe('tagEditReducer', () => {
const newName = 'bar'; const newName = 'bar';
const color = '#ff0000'; const color = '#ff0000';
const apiClientMock = createApiClientMock(Promise.reject(error)); const apiClientMock = createApiClientMock(Promise.reject(error));
const dispatchable = _editTag(apiClientMock, colorGenerator, oldName, newName, color); const dispatchable = editTag(() => apiClientMock, colorGenerator)(oldName, newName, color);
try { try {
await dispatchable(dispatch); await dispatchable(dispatch, getState);
} catch (e) { } catch (e) {
expect(e).toEqual(error); expect(e).toEqual(error);
} }

View file

@ -1,5 +1,5 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { ColorGenerator } from '../../src/utils/ColorGenerator'; import ColorGenerator from '../../../src/utils/services/ColorGenerator';
describe('ColorGenerator', () => { describe('ColorGenerator', () => {
let colorGenerator; let colorGenerator;

View file

@ -1,6 +1,6 @@
import sinon from 'sinon'; import sinon from 'sinon';
import { head, last } from 'ramda'; import { head, last } from 'ramda';
import { ShlinkApiClient } from '../../src/api/ShlinkApiClient'; import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClient', () => { describe('ShlinkApiClient', () => {
const createAxiosMock = (extraData) => () => const createAxiosMock = (extraData) => () =>

View file

@ -0,0 +1,26 @@
import buildShlinkApiClient from '../../../src/utils/services/ShlinkApiClientBuilder';
describe('ShlinkApiClientBuilder', () => {
const builder = buildShlinkApiClient({});
it('creates new instances when provided params are different', () => {
const firstApiClient = builder({ url: 'foo', apiKey: 'bar' });
const secondApiClient = builder({ url: 'bar', apiKey: 'bar' });
const thirdApiClient = builder({ url: 'bar', apiKey: 'foo' });
expect(firstApiClient).not.toBe(secondApiClient);
expect(firstApiClient).not.toBe(thirdApiClient);
expect(secondApiClient).not.toBe(thirdApiClient);
});
it('returns existing instances when provided params are the same', () => {
const params = { url: 'foo', apiKey: 'bar' };
const firstApiClient = builder(params);
const secondApiClient = builder(params);
const thirdApiClient = builder(params);
expect(firstApiClient).toBe(secondApiClient);
expect(firstApiClient).toBe(thirdApiClient);
expect(secondApiClient).toBe(thirdApiClient);
});
});

View file

@ -3,7 +3,7 @@ import { shallow } from 'enzyme';
import { identity } from 'ramda'; import { identity } from 'ramda';
import { Card } from 'reactstrap'; import { Card } from 'reactstrap';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { ShortUrlsVisitsComponent as ShortUrlsVisits } from '../../src/visits/ShortUrlVisits'; import createShortUrlVisits from '../../src/visits/ShortUrlVisits';
import MutedMessage from '../../src/utils/MuttedMessage'; import MutedMessage from '../../src/utils/MuttedMessage';
import GraphCard from '../../src/visits/GraphCard'; import GraphCard from '../../src/visits/GraphCard';
import DateInput from '../../src/utils/DateInput'; import DateInput from '../../src/utils/DateInput';
@ -18,14 +18,17 @@ describe('<ShortUrlVisits />', () => {
}; };
const createComponent = (shortUrlVisits) => { const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({
processBrowserStats: statsProcessor,
processCountriesStats: statsProcessor,
processOsStats: statsProcessor,
processReferrersStats: statsProcessor,
});
wrapper = shallow( wrapper = shallow(
<ShortUrlsVisits <ShortUrlVisits
getShortUrlDetail={identity} getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock} getShortUrlVisits={getShortUrlVisitsMock}
processBrowserStats={statsProcessor}
processCountriesStats={statsProcessor}
processOsStats={statsProcessor}
processReferrersStats={statsProcessor}
match={match} match={match}
shortUrlVisits={shortUrlVisits} shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}} shortUrlDetail={{}}

View file

@ -1,6 +1,6 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import reducer, { import reducer, {
_getShortUrlDetail, getShortUrlDetail,
GET_SHORT_URL_DETAIL_START, GET_SHORT_URL_DETAIL_START,
GET_SHORT_URL_DETAIL_ERROR, GET_SHORT_URL_DETAIL_ERROR,
GET_SHORT_URL_DETAIL, GET_SHORT_URL_DETAIL,
@ -50,6 +50,7 @@ describe('shortUrlDetailReducer', () => {
getShortUrl: sinon.fake.returns(returned), getShortUrl: sinon.fake.returns(returned),
}); });
const dispatchMock = sinon.spy(); const dispatchMock = sinon.spy();
const getState = () => ({});
beforeEach(() => dispatchMock.resetHistory()); beforeEach(() => dispatchMock.resetHistory());
@ -57,7 +58,7 @@ describe('shortUrlDetailReducer', () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject()); const ShlinkApiClient = buildApiClientMock(Promise.reject());
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock); await getShortUrlDetail(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
const [ firstCallArg ] = dispatchMock.getCall(0).args; const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg; const { type: firstCallType } = firstCallArg;
@ -76,7 +77,7 @@ describe('shortUrlDetailReducer', () => {
const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl)); const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedShortUrl));
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
await _getShortUrlDetail(ShlinkApiClient, 'abc123')(dispatchMock); await getShortUrlDetail(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
const [ firstCallArg ] = dispatchMock.getCall(0).args; const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg; const { type: firstCallType } = firstCallArg;

View file

@ -1,6 +1,6 @@
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import reducer, { import reducer, {
_getShortUrlVisits, getShortUrlVisits,
GET_SHORT_URL_VISITS_START, GET_SHORT_URL_VISITS_START,
GET_SHORT_URL_VISITS_ERROR, GET_SHORT_URL_VISITS_ERROR,
GET_SHORT_URL_VISITS, GET_SHORT_URL_VISITS,
@ -50,6 +50,7 @@ describe('shortUrlVisitsReducer', () => {
getShortUrlVisits: sinon.fake.returns(returned), getShortUrlVisits: sinon.fake.returns(returned),
}); });
const dispatchMock = sinon.spy(); const dispatchMock = sinon.spy();
const getState = () => ({});
beforeEach(() => dispatchMock.resetHistory()); beforeEach(() => dispatchMock.resetHistory());
@ -57,7 +58,7 @@ describe('shortUrlVisitsReducer', () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject()); const ShlinkApiClient = buildApiClientMock(Promise.reject());
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock); await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
const [ firstCallArg ] = dispatchMock.getCall(0).args; const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg; const { type: firstCallType } = firstCallArg;
@ -76,7 +77,7 @@ describe('shortUrlVisitsReducer', () => {
const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedVisits)); const ShlinkApiClient = buildApiClientMock(Promise.resolve(resolvedVisits));
const expectedDispatchCalls = 2; const expectedDispatchCalls = 2;
await _getShortUrlVisits(ShlinkApiClient, 'abc123')(dispatchMock); await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState);
const [ firstCallArg ] = dispatchMock.getCall(0).args; const [ firstCallArg ] = dispatchMock.getCall(0).args;
const { type: firstCallType } = firstCallArg; const { type: firstCallType } = firstCallArg;

View file

@ -1316,6 +1316,10 @@ bootstrap@^4.1.1:
version "4.1.3" version "4.1.3"
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be"
bottlejs@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/bottlejs/-/bottlejs-1.7.1.tgz#f2673c42feb2ba092d94b8add390e66b3f7d948f"
boxen@1.3.0, boxen@^1.2.1: boxen@1.3.0, boxen@^1.2.1:
version "1.3.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b" resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"