Merge pull request #137 from acelaya/feature/pre-configure-servers

Feature/pre configure servers
This commit is contained in:
Alejandro Celaya 2019-04-28 18:01:46 +02:00 committed by GitHub
commit 95462b0c1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 188 additions and 80 deletions

1
.gitignore vendored
View file

@ -13,3 +13,4 @@ npm-debug.log*
docker-compose.override.yml
home
public/servers.json*

View file

@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Added
* [#101](https://github.com/shlinkio/shlink-web-client/issues/101) Added checkbox to short URL creation form that allows to determine the value of the `findIfExists` flag introduced in Shlink v1.16.0.
* [#105](https://github.com/shlinkio/shlink-web-client/issues/105) Added support to pre-configure servers.
#### Changed

View file

@ -14,9 +14,9 @@ A ReactJS-based progressive web application for [Shlink](https://shlink.io).
There are three ways in which you can use this application.
* The easiest way to use shlink-web-client is by just going to https://app.shlink.io.
* The easiest way to use shlink-web-client is by just going to <https://app.shlink.io>.
The application runs 100% in the browser, so you can use that instance and access any shlink instance from it.
The application runs 100% in the browser, so you can safely access any shlink instance from there.
* Self hosting the application yourself.
@ -24,27 +24,60 @@ There are three ways in which you can use this application.
The package contains static files only, so just put it in a folder and serve it with the web server of your choice.
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these simple steps](#serve-shlink-in-subpath).
Provided dist files are configured to be served from the root of your domain. If you need to serve shlink-web-client from a subpath, you will have to build it yourself following [these steps](#serve-shlink-in-subpath).
* Use the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
* Using the official [docker image](https://hub.docker.com/r/shlinkio/shlink-web-client/)
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the image and do it.
If you want to deploy shlink-web-client in a container-based cluster (kubernetes, docker swarm, etc), just pick the `shlinkio/shlink-web-client` image and do it.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the assets on port 80.
It's a lightweight [nginx:alpine](https://hub.docker.com/r/library/nginx/) image serving the static app on port 80.
## Pre-configuring servers
The first time you access shlink-web-client from a browser, you will have to configure the list of shlink servers you want to manage, and they will be saved in the local storage.
Those servers can be exported and imported in other browsers, but if for some reason you need some servers to be there from the beginning, starting with shlink-web-client 2.1.0, you can provide a `servers.json` file in the project root folder (the same containing the `index.html`, `favicon.ico`, etc) with a structure like this:
```json
[
{
"name": "Main server",
"url": "https://doma.in",
"apiKey": "09c972b7-506b-49f1-a19a-d729e22e599c"
},
{
"name": "Local",
"url": "http://localhost:8080",
"apiKey": "580d0b42-4dea-419a-96bf-6c876b901451"
}
]
```
> The list can contain as many servers as you need.
If you are using the shlink-web-client docker image, you can mount the `servers.json` file in a volume inside `/usr/share/nginx/html`, which is the app's document root inside the container.
docker run --name shlink-web-client -p 8000:80 -v ${PWD}/servers.json:/usr/share/nginx/html/servers.json shlinkio/shlink-web-client
## Serve project in subpath
Official distributable files have been build so that they are served from the root of a domain.
Official distributable files have been built so that they are served from the root of a domain.
If you need to host shlink-web-client yourself and serve it from a subpath, follow these steps:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.15 or later (if you don't have it yet).
* Download shlink-web-client source files for the version you want to build.
* Download shlink-web-client source code for the version you want to build.
* For example, if you want to build `v1.0.1`, use this link https://github.com/shlinkio/shlink-web-client/archive/v1.0.1.zip
* Replace the `v1.0.1` part in the link with the one of the version you want to build.
* Decompress the file and `cd` into the resulting folder.
* Install project dependencies by running `npm install`.
* Open the `package.json` file in the root of the project, locate the `homepage` property and replace the value (which should be an empty string) by the path from which you want to serve shlink-web-client.
* For example: `"homepage": "/my-projects/shlink-web-client",`.
* Build the distributable contents by running `npm run build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* Build the project:
* For classic hosting:
* Download [node](https://nodejs.org/en/download/package-manager/) 10.15 or later.
* Install project dependencies by running `npm install`.
* Build the project by running `npm run build`.
* Once the command finishes, you will have a `build` folder with all the static assets you need to run shlink-web-client. Just place them wherever you want them to be served from.
* For docker image:
* Download [docker](https://docs.docker.com/install/).
* Build the docker image by running `docker build . -t shlink-web-client`.
* Once the command finishes, you will have an image with the name `shlink-web-client`.

View file

@ -3,7 +3,7 @@
"description": "A React-based progressive web application for shlink",
"version": "1.0.0",
"private": false,
"homepage": "https://shlink.io",
"homepage": "",
"scripts": {
"lint": "npm run lint:js && npm run lint:css",
"lint:js": "eslint src test scripts config",

View file

@ -18,18 +18,20 @@ export default class Home extends React.Component {
}
render() {
const servers = values(this.props.servers);
const { servers: { list, loading } } = this.props;
const servers = values(list);
const hasServers = !isEmpty(servers);
return (
<div className="home">
<h1 className="home__title">Welcome to Shlink</h1>
<h5 className="home__intro">
{hasServers && <span>Please, select a server.</span>}
{!hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{!loading && hasServers && <span>Please, select a server.</span>}
{!loading && !hasServers && <span>Please, <Link to="/server/create">add a server</Link>.</span>}
{loading && <span>Trying to load servers...</span>}
</h5>
{hasServers && (
{!loading && hasServers && (
<ListGroup className="home__servers-list">
{servers.map(({ name, id }) => (
<ListGroupItem

View file

@ -14,7 +14,12 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
};
renderServers = () => {
const { servers, selectedServer, selectServer } = this.props;
const { servers: { list, loading }, selectedServer, selectServer } = this.props;
const servers = values(list);
if (loading) {
return <DropdownItem disabled><i>Trying to load servers...</i></DropdownItem>;
}
if (isEmpty(servers)) {
return <DropdownItem disabled><i>Add a server first...</i></DropdownItem>;
@ -22,7 +27,7 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
return (
<React.Fragment>
{values(servers).map(({ name, id }) => (
{servers.map(({ name, id }) => (
<DropdownItem
key={id}
tag={Link}
@ -46,18 +51,14 @@ const ServersDropdown = (serversExporter) => class ServersDropdown extends React
);
};
componentDidMount() {
this.props.listServers();
}
componentDidMount = this.props.listServers;
render() {
return (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
}
render = () => (
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>Servers</DropdownToggle>
<DropdownMenu right>{this.renderServers()}</DropdownMenu>
</UncontrolledDropdown>
);
};
export default ServersDropdown;

View file

@ -1,7 +1,5 @@
import React from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import { assoc, map } from 'ramda';
import { v4 as uuid } from 'uuid';
import PropTypes from 'prop-types';
const ImportServersBtn = (serversImporter) => class ImportServersBtn extends React.Component {
@ -22,10 +20,8 @@ const ImportServersBtn = (serversImporter) => class ImportServersBtn extends Rea
render() {
const { importServersFromFile } = serversImporter;
const { onImport, createServers } = this.props;
const assocId = (server) => assoc('id', uuid(), server);
const onChange = ({ target }) =>
importServersFromFile(target.files[0])
.then(map(assocId))
.then(createServers)
.then(onImport)
.then(() => {

View file

@ -10,10 +10,10 @@ const initialState = null;
export const resetSelectedServer = createAction(RESET_SELECTED_SERVER);
export const selectServer = (serversService) => (serverId) => (dispatch) => {
export const selectServer = ({ findServerById }) => (serverId) => (dispatch) => {
dispatch(resetShortUrlParams());
const selectedServer = serversService.findServerById(serverId);
const selectedServer = findServerById(serverId);
dispatch({
type: SELECT_SERVER,

View file

@ -1,16 +1,51 @@
import { createAction, handleActions } from 'redux-actions';
import { pipe } from 'ramda';
import { handleActions } from 'redux-actions';
import { pipe, isEmpty, assoc, map, prop } from 'ramda';
import { v4 as uuid } from 'uuid';
import { homepage } from '../../../package.json';
/* eslint-disable padding-line-between-statements */
export const FETCH_SERVERS_START = 'shlink/servers/FETCH_SERVERS_START';
export const FETCH_SERVERS = 'shlink/servers/FETCH_SERVERS';
/* eslint-enable padding-line-between-statements */
export const listServers = ({ listServers }) => createAction(FETCH_SERVERS, () => listServers());
const initialState = {
list: {},
loading: false,
};
export const createServer = ({ createServer }, listServers) => pipe(createServer, listServers);
export const deleteServer = ({ deleteServer }, listServers) => pipe(deleteServer, listServers);
export const createServers = ({ createServers }, listServers) => pipe(createServers, listServers);
const assocId = (server) => assoc('id', server.id || uuid(), server);
export default handleActions({
[FETCH_SERVERS]: (state, { payload }) => payload,
}, {});
[FETCH_SERVERS_START]: (state) => ({ ...state, loading: true }),
[FETCH_SERVERS]: (state, { list }) => ({ list, loading: false }),
}, initialState);
export const listServers = ({ listServers, createServers }, { get }) => () => async (dispatch) => {
dispatch({ type: FETCH_SERVERS_START });
const localList = listServers();
if (!isEmpty(localList)) {
dispatch({ type: FETCH_SERVERS, list: localList });
return;
}
// If local list is empty, try to fetch it remotely and calculate IDs for every server
const remoteList = await get(`${homepage}/servers.json`)
.then(prop('data'))
.then(map(assocId))
.catch(() => []);
createServers(remoteList);
dispatch({ type: FETCH_SERVERS, list: remoteList.reduce((map, server) => ({ ...map, [server.id]: server }), {}) });
};
export const createServer = ({ createServer }, listServersAction) => pipe(createServer, listServersAction);
export const deleteServer = ({ deleteServer }, listServersAction) => pipe(deleteServer, listServersAction);
export const createServers = ({ createServers }, listServersAction) => pipe(
map(assocId),
createServers,
listServersAction
);

View file

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

View file

@ -23,9 +23,6 @@ export default class ServersService {
this.storage.set(SERVERS_STORAGE_KEY, allServers);
};
deleteServer = (server) =>
this.storage.set(
SERVERS_STORAGE_KEY,
dissoc(server.id, this.listServers())
);
deleteServer = ({ id }) =>
this.storage.set(SERVERS_STORAGE_KEY, dissoc(id, this.listServers()));
}

View file

@ -38,7 +38,7 @@ const provideServices = (bottle, connect, withRouter) => {
bottle.serviceFactory('createServer', createServer, 'ServersService', 'listServers');
bottle.serviceFactory('createServers', createServers, 'ServersService', 'listServers');
bottle.serviceFactory('deleteServer', deleteServer, 'ServersService', 'listServers');
bottle.serviceFactory('listServers', listServers, 'ServersService');
bottle.serviceFactory('listServers', listServers, 'ServersService', 'axios');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
};

View file

@ -52,4 +52,4 @@ export const useToggle = (initialValue = false) => {
return [ flag, () => setFlag(!flag) ];
};
export const wait = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds));
export const wait = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));

View file

@ -6,10 +6,8 @@ import Home from '../../src/common/Home';
describe('<Home />', () => {
let wrapped;
const defaultProps = {
resetSelectedServer() {
return '';
},
servers: {},
resetSelectedServer: () => '',
servers: { loading: false, list: {} },
};
const createComponent = (props) => {
const actualProps = { ...defaultProps, ...props };
@ -41,10 +39,22 @@ describe('<Home />', () => {
expect(wrapped.find('ListGroup')).toHaveLength(0);
});
it('shows message when loading servers', () => {
const wrapped = createComponent({ servers: { loading: true } });
const span = wrapped.find('span');
expect(span).toHaveLength(1);
expect(span.text()).toContain('Trying to load servers...');
expect(wrapped.find('ListGroup')).toHaveLength(0);
});
it('shows servers list when list of servers is not empty', () => {
const servers = {
1: { name: 'foo', id: '123' },
2: { name: 'bar', id: '456' },
loading: false,
list: {
1: { name: 'foo', id: '123' },
2: { name: 'bar', id: '456' },
},
};
const wrapped = createComponent({ servers });

View file

@ -8,9 +8,12 @@ describe('<ServersDropdown />', () => {
let wrapped;
let ServersDropdown;
const servers = {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
'3c': { name: 'baz', id: 3 },
list: {
'1a': { name: 'foo', id: 1 },
'2b': { name: 'bar', id: 2 },
'3c': { name: 'baz', id: 3 },
},
loading: false,
};
beforeEach(() => {
@ -20,7 +23,7 @@ describe('<ServersDropdown />', () => {
afterEach(() => wrapped.unmount());
it('contains the list of servers', () =>
expect(wrapped.find(DropdownItem).filter('[to]')).toHaveLength(values(servers).length));
expect(wrapped.find(DropdownItem).filter('[to]')).toHaveLength(values(servers.list).length));
it('contains a toggle with proper title', () =>
expect(wrapped.find(DropdownToggle)).toHaveLength(1));
@ -32,12 +35,21 @@ describe('<ServersDropdown />', () => {
expect(items.filter('.servers-dropdown__export-item')).toHaveLength(1);
});
it('contains a message when no servers exist yet', () => {
wrapped = shallow(<ServersDropdown servers={{}} listServers={identity} />);
it('shows a message when no servers exist yet', () => {
wrapped = shallow(<ServersDropdown servers={{ loading: false, list: {} }} listServers={identity} />);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
expect(item.prop('disabled')).toEqual(true);
expect(item.find('i').text()).toEqual('Add a server first...');
});
it('shows a message when loading', () => {
wrapped = shallow(<ServersDropdown servers={{ loading: true, list: {} }} listServers={identity} />);
const item = wrapped.find(DropdownItem);
expect(item).toHaveLength(1);
expect(item.prop('disabled')).toEqual(true);
expect(item.find('i').text()).toEqual('Trying to load servers...');
});
});

View file

@ -4,17 +4,17 @@ import reducer, {
deleteServer,
listServers,
createServers,
FETCH_SERVERS,
FETCH_SERVERS, FETCH_SERVERS_START,
} from '../../../src/servers/reducers/server';
describe('serverReducer', () => {
const payload = {
const list = {
abc123: { id: 'abc123' },
def456: { id: 'def456' },
};
const expectedFetchServersResult = { type: FETCH_SERVERS, payload };
const expectedFetchServersResult = { type: FETCH_SERVERS, list };
const ServersServiceMock = {
listServers: jest.fn(() => payload),
listServers: jest.fn(() => list),
createServer: jest.fn(),
deleteServer: jest.fn(),
createServers: jest.fn(),
@ -22,7 +22,7 @@ describe('serverReducer', () => {
describe('reducer', () => {
it('returns servers when action is FETCH_SERVERS', () =>
expect(reducer({}, { type: FETCH_SERVERS, payload })).toEqual(payload));
expect(reducer({}, { type: FETCH_SERVERS, list })).toEqual({ loading: false, list }));
});
describe('action creators', () => {
@ -34,14 +34,40 @@ describe('serverReducer', () => {
});
describe('listServers', () => {
it('fetches servers and returns them as part of the action', () => {
const result = listServers(ServersServiceMock)();
const axios = { get: jest.fn().mockResolvedValue({ data: [] }) };
const dispatch = jest.fn();
expect(result).toEqual(expectedFetchServersResult);
beforeEach(() => {
axios.get.mockClear();
dispatch.mockReset();
});
it('fetches servers from local storage when found', async () => {
await listServers(ServersServiceMock, axios)()(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, expectedFetchServersResult);
expect(ServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(ServersServiceMock.createServer).not.toHaveBeenCalled();
expect(ServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(ServersServiceMock.createServers).not.toHaveBeenCalled();
expect(axios.get).not.toHaveBeenCalled();
});
it('tries to fetch servers from remote when not found locally', async () => {
const NoListServersServiceMock = { ...ServersServiceMock, listServers: jest.fn(() => ({})) };
await listServers(NoListServersServiceMock, axios)()(dispatch);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenNthCalledWith(1, { type: FETCH_SERVERS_START });
expect(dispatch).toHaveBeenNthCalledWith(2, { type: FETCH_SERVERS, list: {} });
expect(NoListServersServiceMock.listServers).toHaveBeenCalledTimes(1);
expect(NoListServersServiceMock.createServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.deleteServer).not.toHaveBeenCalled();
expect(NoListServersServiceMock.createServers).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledTimes(1);
});
});
@ -75,7 +101,7 @@ describe('serverReducer', () => {
describe('createServer', () => {
it('creates multiple servers and then fetches servers again', () => {
const serversToCreate = values(payload);
const serversToCreate = values(list);
const result = createServers(ServersServiceMock, () => expectedFetchServersResult)(serversToCreate);
expect(result).toEqual(expectedFetchServersResult);