mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Added EventSource connection to mercure hub possible
This commit is contained in:
parent
37e6c27461
commit
f3129399de
9 changed files with 86 additions and 46 deletions
5
package-lock.json
generated
5
package-lock.json
generated
|
@ -6641,6 +6641,11 @@
|
||||||
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"event-source-polyfill": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-WjOTn0LIbaN08z/8gNt3GYAomAdm6cZ2lr/QdvhTTEipr5KR6lds2ziUH+p/Iob4Lk6NClKhwPOmn1NjQEcJCg=="
|
||||||
|
},
|
||||||
"eventemitter3": {
|
"eventemitter3": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz",
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"compare-versions": "^3.5.1",
|
"compare-versions": "^3.5.1",
|
||||||
"csvjson": "^5.1.0",
|
"csvjson": "^5.1.0",
|
||||||
|
"event-source-polyfill": "^1.0.12",
|
||||||
"leaflet": "^1.5.1",
|
"leaflet": "^1.5.1",
|
||||||
"moment": "^2.24.0",
|
"moment": "^2.24.0",
|
||||||
"promise": "^8.0.3",
|
"promise": "^8.0.3",
|
||||||
|
|
|
@ -17,7 +17,7 @@ export const MercureInfoType = PropTypes.shape({
|
||||||
const initialState = {
|
const initialState = {
|
||||||
token: undefined,
|
token: undefined,
|
||||||
mercureHubUrl: undefined,
|
mercureHubUrl: undefined,
|
||||||
loading: false,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -4,9 +4,11 @@ import { head, isEmpty, keys, values } from 'ramda';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import qs from 'qs';
|
import qs from 'qs';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { EventSourcePolyfill as EventSource } from 'event-source-polyfill';
|
||||||
import { serverType } from '../servers/prop-types';
|
import { serverType } from '../servers/prop-types';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir } from '../utils/utils';
|
import { determineOrderDir } from '../utils/utils';
|
||||||
|
import { MercureInfoType } from '../mercure/reducers/mercureInfo';
|
||||||
import { shortUrlType } from './reducers/shortUrlsList';
|
import { shortUrlType } from './reducers/shortUrlsList';
|
||||||
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
import { shortUrlsListParamsType } from './reducers/shortUrlsListParams';
|
||||||
import './ShortUrlsList.scss';
|
import './ShortUrlsList.scss';
|
||||||
|
@ -30,6 +32,8 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||||
error: PropTypes.bool,
|
error: PropTypes.bool,
|
||||||
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
shortUrlsList: PropTypes.arrayOf(shortUrlType),
|
||||||
selectedServer: serverType,
|
selectedServer: serverType,
|
||||||
|
createNewVisit: PropTypes.func,
|
||||||
|
mercureInfo: MercureInfoType,
|
||||||
};
|
};
|
||||||
|
|
||||||
refreshList = (extraParams) => {
|
refreshList = (extraParams) => {
|
||||||
|
@ -85,12 +89,40 @@ const ShortUrlsList = (ShortUrlsRow) => class ShortUrlsList extends React.Compon
|
||||||
this.refreshList({ page: params.page, tags });
|
this.refreshList({ page: params.page, tags });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
const { mercureHubUrl, token, loading, error } = this.props.mercureInfo;
|
||||||
|
|
||||||
|
if (loading || error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hubUrl = new URL(mercureHubUrl);
|
||||||
|
|
||||||
|
hubUrl.searchParams.append('topic', 'https://shlink.io/new-visit');
|
||||||
|
this.closeEventSource();
|
||||||
|
this.es = new EventSource(hubUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.es.onmessage = ({ data }) => this.props.createNewVisit(JSON.parse(data));
|
||||||
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
const { resetShortUrlParams } = this.props;
|
const { resetShortUrlParams } = this.props;
|
||||||
|
|
||||||
|
this.closeEventSource();
|
||||||
resetShortUrlParams();
|
resetShortUrlParams();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeEventSource = () => {
|
||||||
|
if (this.es) {
|
||||||
|
this.es.close();
|
||||||
|
this.es = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
renderShortUrls() {
|
renderShortUrls() {
|
||||||
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
const { shortUrlsList, selectedServer, loading, error, shortUrlsListParams } = this.props;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { handleActions } from 'redux-actions';
|
import { handleActions } from 'redux-actions';
|
||||||
import { assoc, assocPath, isNil, reject } from 'ramda';
|
import { assoc, assocPath, isNil, reject } from 'ramda';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { CREATE_SHORT_URL_VISIT } from '../../visits/reducers/shortUrlVisits';
|
||||||
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
import { SHORT_URL_TAGS_EDITED } from './shortUrlTags';
|
||||||
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
import { SHORT_URL_META_EDITED, shortUrlMetaType } from './shortUrlMeta';
|
||||||
|
@ -56,6 +57,15 @@ export default handleActions({
|
||||||
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
[SHORT_URL_TAGS_EDITED]: setPropFromActionOnMatchingShortUrl('tags'),
|
||||||
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
[SHORT_URL_META_EDITED]: setPropFromActionOnMatchingShortUrl('meta'),
|
||||||
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
[SHORT_URL_EDITED]: setPropFromActionOnMatchingShortUrl('longUrl'),
|
||||||
|
[CREATE_SHORT_URL_VISIT]: (state, { shortUrl: { shortCode, domain, visitsCount } }) => assocPath(
|
||||||
|
[ 'shortUrls', 'data' ],
|
||||||
|
state.shortUrls.data.map(
|
||||||
|
(shortUrl) => shortUrlMatches(shortUrl, shortCode, domain)
|
||||||
|
? assoc('visitsCount', visitsCount, shortUrl)
|
||||||
|
: shortUrl
|
||||||
|
),
|
||||||
|
state
|
||||||
|
),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
export const listShortUrls = (buildShlinkApiClient) => (params = {}) => async (dispatch, getState) => {
|
||||||
|
|
|
@ -31,8 +31,8 @@ const provideServices = (bottle, connect) => {
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsRow');
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams' ]
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisit' ]
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
bottle.serviceFactory('ShortUrlsRow', ShortUrlsRow, 'ShortUrlsRowMenu', 'ColorGenerator', 'useStateFlagTimeout');
|
||||||
|
|
|
@ -8,6 +8,7 @@ export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_V
|
||||||
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
|
export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS';
|
||||||
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
|
export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE';
|
||||||
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL';
|
||||||
|
export const CREATE_SHORT_URL_VISIT = 'shlink/shortUrlVisits/CREATE_SHORT_URL_VISIT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export const visitType = PropTypes.shape({
|
export const visitType = PropTypes.shape({
|
||||||
|
@ -63,6 +64,9 @@ export default handleActions({
|
||||||
}),
|
}),
|
||||||
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
[CREATE_SHORT_URL_VISIT]: (state) => state,
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
|
export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) => async (dispatch, getState) => {
|
||||||
|
@ -125,3 +129,5 @@ export const getShortUrlVisits = (buildShlinkApiClient) => (shortCode, query) =>
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
|
export const cancelGetShortUrlVisits = createAction(GET_SHORT_URL_VISITS_CANCEL);
|
||||||
|
|
||||||
|
export const createNewVisit = ({ shortUrl, visit }) => ({ shortUrl, visit, type: CREATE_SHORT_URL_VISIT });
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import ShortUrlVisits from '../ShortUrlVisits';
|
import ShortUrlVisits from '../ShortUrlVisits';
|
||||||
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits';
|
import { cancelGetShortUrlVisits, createNewVisit, getShortUrlVisits } from '../reducers/shortUrlVisits';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
|
import OpenMapModalBtn from '../helpers/OpenMapModalBtn';
|
||||||
import MapModal from '../helpers/MapModal';
|
import MapModal from '../helpers/MapModal';
|
||||||
|
@ -22,6 +22,7 @@ const provideServices = (bottle, connect) => {
|
||||||
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
|
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits);
|
||||||
|
bottle.serviceFactory('createNewVisit', () => createNewVisit);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -36,13 +36,13 @@ describe('<ShortUrlsList />', () => {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
mercureInfo={{ loading: true }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
listShortUrlsMock.mockReset();
|
jest.resetAllMocks();
|
||||||
resetShortUrlParamsMock.mockReset();
|
|
||||||
wrapper && wrapper.unmount();
|
wrapper && wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -55,25 +55,19 @@ describe('<ShortUrlsList />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render table header by default', () => {
|
it('should render table header by default', () => {
|
||||||
expect(wrapper.find('table').shallow().find('thead')).toHaveLength(1);
|
expect(wrapper.find('table').find('thead')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render 6 table header cells by default', () => {
|
it('should render 6 table header cells by default', () => {
|
||||||
expect(wrapper.find('table').shallow()
|
expect(wrapper.find('table').find('thead').find('tr').find('th')).toHaveLength(6);
|
||||||
.find('thead').shallow()
|
|
||||||
.find('tr').shallow()
|
|
||||||
.find('th')).toHaveLength(6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render 6 table header cells without order by icon by default', () => {
|
it('should render 6 table header cells without order by icon by default', () => {
|
||||||
const thElements = wrapper.find('table').shallow()
|
const thElements = wrapper.find('table').find('thead').find('tr').find('th');
|
||||||
.find('thead').shallow()
|
|
||||||
.find('tr').shallow()
|
|
||||||
.find('th').map((e) => e.shallow());
|
|
||||||
|
|
||||||
for (const thElement of thElements) {
|
thElements.forEach((thElement) => {
|
||||||
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
|
expect(thElement.find(FontAwesomeIcon)).toHaveLength(0);
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render 6 table header cells with conditional order by icon', () => {
|
it('should render 6 table header cells with conditional order by icon', () => {
|
||||||
|
@ -81,40 +75,31 @@ describe('<ShortUrlsList />', () => {
|
||||||
ASC: caretUpIcon,
|
ASC: caretUpIcon,
|
||||||
DESC: caretDownIcon,
|
DESC: caretDownIcon,
|
||||||
};
|
};
|
||||||
|
const getThElementForSortableField = (sortableField) => wrapper.find('table')
|
||||||
for (const sortableField of Object.getOwnPropertyNames(SORTABLE_FIELDS)) {
|
.find('thead')
|
||||||
wrapper.setState({ orderField: sortableField, orderDir: undefined });
|
.find('tr')
|
||||||
const [ sortableThElement ] = wrapper.find('table').shallow()
|
|
||||||
.find('thead').shallow()
|
|
||||||
.find('tr').shallow()
|
|
||||||
.find('th')
|
.find('th')
|
||||||
.filterWhere(
|
.filterWhere((e) => e.text().includes(SORTABLE_FIELDS[sortableField]));
|
||||||
(e) =>
|
|
||||||
e.text().includes(SORTABLE_FIELDS[sortableField])
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortableThElementWrapper = shallow(sortableThElement);
|
Object.keys(SORTABLE_FIELDS).forEach((sortableField) => {
|
||||||
|
const sortableThElementWrapper = getThElementForSortableField(sortableField);
|
||||||
|
|
||||||
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
|
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
|
||||||
|
|
||||||
for (const orderDir of Object.getOwnPropertyNames(orderDirOptionToIconMap)) {
|
sortableThElementWrapper.simulate('click');
|
||||||
wrapper.setState({ orderField: sortableField, orderDir });
|
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
|
||||||
const [ sortableThElement ] = wrapper.find('table').shallow()
|
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(
|
||||||
.find('thead').shallow()
|
orderDirOptionToIconMap.ASC,
|
||||||
.find('tr').shallow()
|
|
||||||
.find('th')
|
|
||||||
.filterWhere(
|
|
||||||
(e) =>
|
|
||||||
e.text().includes(SORTABLE_FIELDS[sortableField])
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortableThElementWrapper = shallow(sortableThElement);
|
sortableThElementWrapper.simulate('click');
|
||||||
|
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon)).toHaveLength(1);
|
||||||
|
expect(getThElementForSortableField(sortableField).find(FontAwesomeIcon).prop('icon')).toEqual(
|
||||||
|
orderDirOptionToIconMap.DESC,
|
||||||
|
);
|
||||||
|
|
||||||
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(1);
|
sortableThElementWrapper.simulate('click');
|
||||||
expect(
|
expect(sortableThElementWrapper.find(FontAwesomeIcon)).toHaveLength(0);
|
||||||
sortableThElementWrapper.find(FontAwesomeIcon).prop('icon')
|
});
|
||||||
).toEqual(orderDirOptionToIconMap[orderDir]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue