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).
## [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
#### Added

View file

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

View file

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

View file

@ -1,13 +1,8 @@
import React from 'react';
import { Route, Switch } from 'react-router-dom';
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() {
return (
const App = (MainHeader, Home, MenuLayout, CreateServer) => () => (
<div className="container-fluid app-container">
<MainHeader />
@ -19,5 +14,6 @@ export default function App() {
</Switch>
</div>
</div>
);
}
);
export default App;

View file

@ -6,9 +6,8 @@ import React from 'react';
import { NavLink } from 'react-router-dom';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import DeleteServerButton from '../servers/DeleteServerButton';
import './AsideMenu.scss';
import { serverType } from '../servers/prop-types';
import './AsideMenu.scss';
const defaultProps = {
className: '',
@ -20,7 +19,8 @@ const propTypes = {
showOnMobile: PropTypes.bool,
};
export default function AsideMenu({ selectedServer, className, showOnMobile }) {
const AsideMenu = (DeleteServerButton) => {
const AsideMenu = ({ selectedServer, className, showOnMobile }) => {
const serverId = selectedServer ? selectedServer.id : '';
const asideClass = classnames('aside-menu', className, {
'aside-menu--hidden': !showOnMobile,
@ -64,7 +64,12 @@ export default function AsideMenu({ selectedServer, className, showOnMobile }) {
</nav>
</aside>
);
}
};
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
AsideMenu.defaultProps = defaultProps;
AsideMenu.propTypes = propTypes;
return AsideMenu;
};
export default AsideMenu;

View file

@ -1,15 +1,13 @@
import chevronIcon from '@fortawesome/fontawesome-free-solid/faChevronRight';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, pick, values } from 'ramda';
import { isEmpty, values } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { ListGroup, ListGroupItem } from 'reactstrap';
import PropTypes from 'prop-types';
import { resetSelectedServer } from '../servers/reducers/selectedServer';
import './Home.scss';
export class HomeComponent extends React.Component {
export default class Home extends React.Component {
static propTypes = {
resetSelectedServer: PropTypes.func,
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 FontAwesomeIcon from '@fortawesome/react-fontawesome';
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 classnames from 'classnames';
import PropTypes from 'prop-types';
import ServersDropdown from '../servers/ServersDropdown';
import './MainHeader.scss';
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 = {
location: PropTypes.object,
};
@ -62,8 +61,6 @@ export class MainHeaderComponent extends React.Component {
</Navbar>
);
}
}
const MainHeader = withRouter(MainHeaderComponent);
};
export default MainHeader;

View file

@ -1,23 +1,15 @@
import React from 'react';
import { Route, Switch, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { pick } from 'ramda';
import { Route, Switch } from 'react-router-dom';
import Swipeable from 'react-swipeable';
import burgerIcon from '@fortawesome/fontawesome-free-solid/faBars';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import classnames from 'classnames';
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 AsideMenu from './AsideMenu';
import './MenuLayout.scss';
export class MenuLayoutComponent extends React.Component {
const MenuLayout = (TagsList, ShortUrls, AsideMenu, CreateShortUrl, ShortUrlVisits) =>
class MenuLayout extends React.Component {
static propTypes = {
match: PropTypes.object,
selectServer: PropTypes.func,
@ -89,7 +81,7 @@ export class MenuLayoutComponent extends React.Component {
<Route
exact
path="/server/:serverId/short-code/:shortCode/visits"
component={ShortUrlsVisits}
component={ShortUrlVisits}
/>
<Route
exact
@ -103,11 +95,6 @@ export class MenuLayoutComponent extends React.Component {
</React.Fragment>
);
}
}
const MenuLayout = compose(
connect(pick([ 'selectedServer', 'shortUrlsListParams' ]), { selectServer }),
withRouter
)(MenuLayoutComponent);
};
export default MenuLayout;

View file

@ -1,8 +1,7 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import PropTypes from 'prop-types';
export class ScrollToTopComponent extends React.Component {
export default class ScrollToTop extends React.Component {
static propTypes = {
location: PropTypes.object,
window: PropTypes.shape({
@ -11,7 +10,7 @@ export class ScrollToTopComponent extends React.Component {
children: PropTypes.node,
};
static defaultProps = {
window,
window: global.window,
};
componentDidUpdate(prevProps) {
@ -26,7 +25,3 @@ export class ScrollToTopComponent extends React.Component {
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 { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import { applyMiddleware, compose, createStore } from 'redux';
import ReduxThunk from 'redux-thunk';
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 container from './container';
import store from './container/store';
import '../node_modules/react-datepicker/dist/react-datepicker.css';
import './common/react-tagsinput.scss';
import './index.scss';
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)
));
const { App, ScrollToTop } = container;
render(
<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 { connect } from 'react-redux';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
import { stateFlagTimeout } from '../utils/utils';
import { resetSelectedServer } from './reducers/selectedServer';
import { createServer } from './reducers/server';
import './CreateServer.scss';
import ImportServersBtn from './helpers/ImportServersBtn';
const SHOW_IMPORT_MSG_TIME = 4000;
export class CreateServerComponent extends React.Component {
const CreateServer = (ImportServersBtn) => class CreateServer extends React.Component {
static propTypes = {
createServer: PropTypes.func,
history: PropTypes.shape({
@ -91,11 +87,6 @@ export class CreateServerComponent extends React.Component {
</div>
);
}
}
const CreateServer = connect(
pick([ 'selectedServer' ]),
{ createServer, resetSelectedServer }
)(CreateServerComponent);
};
export default CreateServer;

View file

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

View file

@ -1,10 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import { compose } from 'redux';
import { deleteServer } from './reducers/server';
import { serverType } from './prop-types';
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 = () => {
deleteServer(server);
toggle();
@ -42,11 +38,6 @@ export const DeleteServerModalComponent = ({ server, toggle, isOpen, deleteServe
);
};
DeleteServerModalComponent.propTypes = propTypes;
const DeleteServerModal = compose(
withRouter,
connect(null, { deleteServer })
)(DeleteServerModalComponent);
DeleteServerModal.propTypes = propTypes;
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 { connect } from 'react-redux';
import { Link } from 'react-router-dom';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
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';
export class ServersDropdownComponent extends React.Component {
static defaultProps = {
serversExporter,
};
const ServersDropdown = (serversExporter) => class ServersDropdown extends React.Component {
static propTypes = {
servers: PropTypes.object,
serversExporter: PropTypes.shape({
exportServers: PropTypes.func,
}),
selectedServer: serverType,
selectServer: PropTypes.func,
listServers: PropTypes.func,
};
renderServers = () => {
const { servers, selectedServer, selectServer, serversExporter } = this.props;
const { servers, selectedServer, selectServer } = this.props;
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@ -68,11 +58,6 @@ export class ServersDropdownComponent extends React.Component {
</UncontrolledDropdown>
);
}
}
const ServersDropdown = connect(
pick([ 'servers', 'selectedServer' ]),
{ listServers, selectServer }
)(ServersDropdownComponent);
};
export default ServersDropdown;

View file

@ -1,20 +1,15 @@
import React from 'react';
import { connect } from 'react-redux';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc } from 'ramda';
import { v4 as uuid } from 'uuid';
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 = {
serversImporter,
onImport: () => ({}),
};
static propTypes = {
onImport: PropTypes.func,
serversImporter: serversImporterType,
createServers: PropTypes.func,
fileRef: PropTypes.oneOfType([ PropTypes.object, PropTypes.node ]),
};
@ -25,7 +20,8 @@ export class ImportServersBtnComponent extends React.Component {
}
render() {
const { serversImporter: { importServersFromFile }, onImport, createServers } = this.props;
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const onChange = (e) =>
importServersFromFile(e.target.files[0])
.then((servers) => servers.map((server) => assoc('id', uuid(), server)))
@ -56,8 +52,6 @@ export class ImportServersBtnComponent extends React.Component {
</React.Fragment>
);
}
}
const ImportServersBtn = connect(null, { createServers })(ImportServersBtnComponent);
};
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';
/* 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 _selectServer = (shlinkApiClient, serversService, serverId) => (dispatch) => {
export const selectServer = (serversService) => (serverId) => (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
shlinkApiClient.setConfig(selectedServer);
dispatch({
type: SELECT_SERVER,
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 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,
servers: serversService.listServers(),
});
export const listServers = () => _listServers(serversService);
export const _createServer = (serversService, server) => {
export const createServer = (serversService) => (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);
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);
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 csvjson from 'csvjson';
import serversService from './ServersService';
const saveCsv = (window, csv) => {
const { navigator, document } = window;
@ -26,7 +24,7 @@ const saveCsv = (window, csv) => {
document.body.removeChild(link);
};
export class ServersExporter {
export default class ServersExporter {
constructor(serversService, window, csvjson) {
this.serversService = serversService;
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';
export const serversImporterType = PropTypes.shape({
importServersFromFile: PropTypes.func,
});
export class ServersImporter {
export default class ServersImporter {
constructor(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 storage from '../../utils/Storage';
const SERVERS_STORAGE_KEY = 'servers';
export class ServersService {
export default class ServersService {
constructor(storage) {
this.storage = storage;
}
@ -30,7 +29,3 @@ export class ServersService {
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 upIcon from '@fortawesome/fontawesome-free-solid/faAngleDoubleUp';
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 { connect } from 'react-redux';
import { Collapse } from 'reactstrap';
import * as PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import TagsSelector from '../tags/helpers/TagsSelector';
import CreateShortUrlResult from './helpers/CreateShortUrlResult';
import { createShortUrl, createShortUrlResultType, resetCreateShortUrl } from './reducers/shortUrlCreation';
import { createShortUrlResultType } from './reducers/shortUrlCreation';
const normalizeTag = pipe(trim, replace(/ /g, '-'));
const formatDate = (date) => isNil(date) ? date : date.format();
export class CreateShortUrlComponent extends React.Component {
const CreateShortUrl = (TagsSelector) => class CreateShortUrl extends React.Component {
static propTypes = {
createShortUrl: PropTypes.func,
shortUrlCreationResult: createShortUrlResultType,
@ -122,11 +120,6 @@ export class CreateShortUrlComponent extends React.Component {
</div>
);
}
}
const CreateShortUrl = connect(pick([ 'shortUrlCreationResult' ]), {
createShortUrl,
resetCreateShortUrl,
})(CreateShortUrlComponent);
};
export default CreateShortUrl;

View file

@ -1,21 +1,20 @@
import tagsIcon from '@fortawesome/fontawesome-free-solid/faTags';
import FontAwesomeIcon from '@fortawesome/react-fontawesome';
import React from 'react';
import { connect } from 'react-redux';
import { isEmpty, pick } from 'ramda';
import { isEmpty } from 'ramda';
import PropTypes from 'prop-types';
import Tag from '../tags/helpers/Tag';
import SearchField from '../utils/SearchField';
import { listShortUrls } from './reducers/shortUrlsList';
import './SearchBar.scss';
import Tag from '../tags/helpers/Tag';
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
import './SearchBar.scss';
const propTypes = {
listShortUrls: PropTypes.func,
shortUrlsListParams: shortUrlsListParamsType,
};
export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
const SearchBar = (colorGenerator) => {
const SearchBar = ({ listShortUrls, shortUrlsListParams }) => {
const selectedTags = shortUrlsListParams.tags || [];
return (
@ -31,6 +30,7 @@ export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
&nbsp;
{selectedTags.map((tag) => (
<Tag
colorGenerator={colorGenerator}
key={tag}
text={tag}
clearable
@ -46,10 +46,11 @@ export function SearchBarComponent({ listShortUrls, shortUrlsListParams }) {
)}
</div>
);
}
};
SearchBarComponent.propTypes = propTypes;
SearchBar.propTypes = propTypes;
const SearchBar = connect(pick([ 'shortUrlsListParams' ]), { listShortUrls })(SearchBarComponent);
return SearchBar;
};
export default SearchBar;

View file

@ -1,27 +1,21 @@
import React from 'react';
import { connect } from 'react-redux';
import { assoc } from 'ramda';
import Paginator from './Paginator';
import SearchBar from './SearchBar';
import ShortUrlsList from './ShortUrlsList';
export function ShortUrlsComponent(props) {
const { match: { params } } = props;
const ShortUrls = (SearchBar, ShortUrlsList) => (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
const urlsListKey = `${params.serverId}_${params.page}`;
const urlsListKey = `${serverId}_${page}`;
return (
<div className="shlink-container">
<div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={props.shortUrlsList.data || []} key={urlsListKey} />
<Paginator paginator={props.shortUrlsList.pagination} serverId={props.match.params.serverId} />
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
);
}
const ShortUrls = connect(
(state) => assoc('shortUrlsList', state.shortUrlsList.shortUrls, state.shortUrlsList)
)(ShortUrlsComponent);
};
export default ShortUrls;

View file

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

View file

@ -1,18 +1,11 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import React from 'react';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick, identity } from 'ramda';
import { identity } from 'ramda';
import { shortUrlType } from '../reducers/shortUrlsList';
import {
deleteShortUrl,
resetDeleteShortUrl,
shortUrlDeleted,
shortUrlDeletionType,
} from '../reducers/shortUrlDeletion';
import './QrCodeModal.scss';
import { shortUrlDeletionType } from '../reducers/shortUrlDeletion';
export class DeleteShortUrlModalComponent extends Component {
export default class DeleteShortUrlModal extends React.Component {
static propTypes = {
shortUrl: shortUrlType,
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 { connect } from 'react-redux';
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
import PropTypes from 'prop-types';
import { pick } from 'ramda';
import TagsSelector from '../../tags/helpers/TagsSelector';
import {
editShortUrlTags,
resetShortUrlsTags,
shortUrlTagsType,
shortUrlTagsEdited,
} from '../reducers/shortUrlTags';
import { shortUrlTagsType } from '../reducers/shortUrlTags';
import ExternalLink from '../../utils/ExternalLink';
import { shortUrlType } from '../reducers/shortUrlsList';
export class EditTagsModalComponent extends React.Component {
const EditTagsModal = (TagsSelector) => class EditTagsModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
toggle: PropTypes.func.isRequired,
@ -88,11 +80,6 @@ export class EditTagsModalComponent extends React.Component {
</Modal>
);
}
}
const EditTagsModal = connect(
pick([ 'shortUrlTags' ]),
{ editShortUrlTags, resetShortUrlsTags, shortUrlTagsEdited }
)(EditTagsModalComponent);
};
export default EditTagsModal;

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
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 });

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteShortUrl(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 shortUrlDeleted = (shortCode) => ({ type: SHORT_URL_DELETED, shortCode });

View file

@ -1,6 +1,5 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
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:
return {
shortCode: action.shortCode,
tags: action.tags,
...pick([ 'shortCode', 'tags' ], action),
saving: 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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
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 shortUrlTagsEdited = (shortCode, tags) => ({

View file

@ -1,6 +1,5 @@
import { assoc, assocPath, reject } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
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 });
const { selectedServer = {} } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
const shortUrls = await shlinkApiClient.listShortUrls(params);
@ -66,5 +68,3 @@ export const _listShortUrls = (shlinkApiClient, params = {}) => async (dispatch)
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 TagBullet from './helpers/TagBullet';
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 = {
tag: PropTypes.string,
currentServerId: PropTypes.string,
@ -35,7 +33,7 @@ export default class TagCard extends React.Component {
<FontAwesomeIcon icon={editIcon} />
</button>
<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>
</h5>
</CardBody>
@ -45,4 +43,6 @@ export default class TagCard extends React.Component {
</Card>
);
}
}
};
export default TagCard;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.deleteTags([ 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 });

View file

@ -1,6 +1,4 @@
import { curry, pick } from 'ramda';
import shlinkApiClient from '../../api/ShlinkApiClient';
import colorGenerator from '../../utils/ColorGenerator';
import { pick } from 'ramda';
/* eslint-disable padding-line-between-statements, newline-after-var */
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
await shlinkApiClient.editTag(oldName, newName);
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) => ({
type: TAG_EDITED,
oldName,

View file

@ -1,5 +1,5 @@
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_EDITED } from './tagEdit';
@ -66,8 +66,8 @@ export default function reducer(state = defaultState, action) {
}
}
export const _listTags = (shlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList } = getState();
export const _listTags = (buildShlinkApiClient, force = false) => async (dispatch, getState) => {
const { tagsList, selectedServer } = getState();
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
return;
@ -76,6 +76,7 @@ export const _listTags = (shlinkApiClient, force = false) => async (dispatch, ge
dispatch({ type: LIST_TAGS_START });
try {
const shlinkApiClient = buildShlinkApiClient(selectedServer);
const tags = await shlinkApiClient.listTags();
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) => ({
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 PropTypes from 'prop-types';
import storage from './Storage';
const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
@ -13,7 +12,7 @@ const buildRandomColor = () =>
}`;
const normalizeKey = (key) => key.toLowerCase().trim();
export class ColorGenerator {
export default class ColorGenerator {
constructor(storage) {
this.storage = storage;
this.colors = this.storage.get('colors') || {};
@ -45,7 +44,3 @@ export const colorGeneratorType = PropTypes.shape({
getColorForKey: 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 { isEmpty, isNil, reject } from 'ramda';
const API_VERSION = '1';
const STATUS_UNAUTHORIZED = 401;
const buildRestUrl = (url) => url ? `${url}/rest/v${API_VERSION}` : '';
export class ShlinkApiClient {
constructor(axios) {
export default class ShlinkApiClient {
constructor(axios, baseUrl, apiKey) {
this.axios = axios;
this._baseUrl = '';
this._apiKey = '';
this._baseUrl = buildRestUrl(baseUrl);
this._apiKey = apiKey || '';
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 = {}) =>
this._performRequest('/short-codes', 'GET', options)
.then((resp) => resp.data.shortUrls)
@ -113,7 +105,3 @@ export class ShlinkApiClient {
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 buildPath = (path) => `${PREFIX}.${path}`;
export class Storage {
export default class Storage {
constructor(localStorage) {
this.localStorage = localStorage;
}
@ -14,15 +14,3 @@ export class Storage {
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 FontAwesomeIcon from '@fortawesome/react-fontawesome';
import { isEmpty, mapObjIndexed, pick } from 'ramda';
import { isEmpty, mapObjIndexed } from 'ramda';
import React from 'react';
import { connect } from 'react-redux';
import { Card } from 'reactstrap';
import PropTypes from 'prop-types';
import DateInput from '../utils/DateInput';
import MutedMessage from '../utils/MuttedMessage';
import SortableBarGraph from './SortableBarGraph';
import { getShortUrlVisits, shortUrlVisitsType } from './reducers/shortUrlVisits';
import {
processBrowserStats,
processCountriesStats,
processOsStats,
processReferrersStats,
} from './services/VisitsParser';
import { shortUrlVisitsType } from './reducers/shortUrlVisits';
import { VisitsHeader } from './VisitsHeader';
import GraphCard from './GraphCard';
import { getShortUrlDetail, shortUrlDetailType } from './reducers/shortUrlDetail';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
export class ShortUrlsVisitsComponent extends React.Component {
const ShortUrlVisits = ({
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
}) => class ShortUrlVisits extends React.Component {
static propTypes = {
processOsStats: PropTypes.func,
processBrowserStats: PropTypes.func,
processCountriesStats: PropTypes.func,
processReferrersStats: PropTypes.func,
match: PropTypes.shape({
params: PropTypes.object,
}),
@ -34,12 +28,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
getShortUrlDetail: PropTypes.func,
shortUrlDetail: shortUrlDetailType,
};
static defaultProps = {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
};
state = { startDate: undefined, endDate: undefined };
loadVisits = () => {
@ -59,14 +47,7 @@ export class ShortUrlsVisitsComponent extends React.Component {
}
render() {
const {
processOsStats,
processBrowserStats,
processCountriesStats,
processReferrersStats,
shortUrlVisits,
shortUrlDetail,
} = this.props;
const { shortUrlVisits, shortUrlDetail } = this.props;
const renderVisitsContent = () => {
const { visits, loading, error } = shortUrlVisits;
@ -153,11 +134,6 @@ export class ShortUrlsVisitsComponent extends React.Component {
</div>
);
}
}
};
const ShortUrlsVisits = connect(
pick([ 'shortUrlVisits', 'shortUrlDetail' ]),
{ getShortUrlVisits, getShortUrlDetail }
)(ShortUrlsVisitsComponent);
export default ShortUrlsVisits;
export default ShortUrlVisits;

View file

@ -1,6 +1,4 @@
import { curry } from 'ramda';
import PropTypes from 'prop-types';
import shlinkApiClient from '../../api/ShlinkApiClient';
import { shortUrlType } from '../../short-urls/reducers/shortUrlsList';
/* 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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
const shortUrl = await shlinkApiClient.getShortUrl(shortCode);
@ -56,5 +57,3 @@ export const _getShortUrlDetail = (shlinkApiClient, shortCode) => async (dispatc
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 shlinkApiClient from '../../api/ShlinkApiClient';
/* eslint-disable padding-line-between-statements, newline-after-var */
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 });
const { selectedServer } = getState();
const shlinkApiClient = buildShlinkApiClient(selectedServer);
try {
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 });
}
};
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 { shallow } from 'enzyme';
import { Route } from 'react-router-dom';
import App from '../src/App';
import MainHeader from '../src/common/MainHeader';
import { identity } from 'ramda';
import appFactory from '../src/App';
describe('<App />', () => {
let wrapper;
const MainHeader = () => '';
beforeEach(() => {
const App = appFactory(MainHeader, identity, identity, identity);
wrapper = shallow(<App />);
});
afterEach(() => wrapper.unmount());

View file

@ -1,12 +1,15 @@
import { shallow } from 'enzyme';
import React from 'react';
import { NavLink } from 'react-router-dom';
import AsideMenu from '../../src/common/AsideMenu';
import asideMenuCreator from '../../src/common/AsideMenu';
describe('<AsideMenu />', () => {
let wrapped;
const DeleteServerButton = () => '';
beforeEach(() => {
const AsideMenu = asideMenuCreator(DeleteServerButton);
wrapped = shallow(<AsideMenu selectedServer={{ id: 'abc123' }} />);
});
afterEach(() => wrapped.unmount());
@ -20,6 +23,6 @@ describe('<AsideMenu />', () => {
});
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 React from 'react';
import * as sinon from 'sinon';
import { HomeComponent } from '../../src/common/Home';
import Home from '../../src/common/Home';
describe('<Home />', () => {
let wrapped;
@ -15,7 +15,7 @@ describe('<Home />', () => {
const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props };
wrapped = shallow(<HomeComponent {...actualProps} />);
wrapped = shallow(<Home {...actualProps} />);
return wrapped;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { identity } from 'ramda';
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 />', () => {
let wrapper;

View file

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

View file

@ -1,13 +1,15 @@
import React from 'react';
import { shallow } from 'enzyme';
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';
describe('<TagCard />', () => {
let wrapper;
beforeEach(() => {
const TagCard = createTagCard(() => '', () => '', {});
wrapper = shallow(<TagCard tag="ssr" currentServerId="1" />);
});
afterEach(() => wrapper.unmount());

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import sinon from 'sinon';
import { head, last } from 'ramda';
import { ShlinkApiClient } from '../../src/api/ShlinkApiClient';
import ShlinkApiClient from '../../../src/utils/services/ShlinkApiClient';
describe('ShlinkApiClient', () => {
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 { Card } from 'reactstrap';
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 GraphCard from '../../src/visits/GraphCard';
import DateInput from '../../src/utils/DateInput';
@ -18,14 +18,17 @@ describe('<ShortUrlVisits />', () => {
};
const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({
processBrowserStats: statsProcessor,
processCountriesStats: statsProcessor,
processOsStats: statsProcessor,
processReferrersStats: statsProcessor,
});
wrapper = shallow(
<ShortUrlsVisits
<ShortUrlVisits
getShortUrlDetail={identity}
getShortUrlVisits={getShortUrlVisitsMock}
processBrowserStats={statsProcessor}
processCountriesStats={statsProcessor}
processOsStats={statsProcessor}
processReferrersStats={statsProcessor}
match={match}
shortUrlVisits={shortUrlVisits}
shortUrlDetail={{}}

View file

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

View file

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

View file

@ -1316,6 +1316,10 @@ bootstrap@^4.1.1:
version "4.1.3"
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:
version "1.3.0"
resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"