mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-26 10:58:22 +03:00
commit
552169ee77
116 changed files with 1791 additions and 600 deletions
34
CHANGELOG.md
34
CHANGELOG.md
|
@ -4,6 +4,40 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.5.0] - 2022-01-01
|
||||||
|
### Added
|
||||||
|
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
|
||||||
|
|
||||||
|
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
|
||||||
|
|
||||||
|
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
|
||||||
|
|
||||||
|
* [#547](https://github.com/shlinkio/shlink-web-client/pull/547) Improved domains page, to tell which of the domains are not properly configured.
|
||||||
|
|
||||||
|
Now, when this section is loaded, it tries to call the `GET /rest/health` endpoint for each one of the domains, and displays a warning icon on each one that failed.
|
||||||
|
|
||||||
|
The warning includes a link to the documentation, explaining what are the steps to get it fixed.
|
||||||
|
|
||||||
|
* [#506](https://github.com/shlinkio/shlink-web-client/pull/506) Improved how servers are handled, displaying a warning when creating or importing servers that already exist.
|
||||||
|
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
|
||||||
|
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
|
||||||
|
* [#537](https://github.com/shlinkio/shlink-web-client/pull/537) Allowed to customize the ordering for every list in the app that supports it, being currently tags and short URLs.
|
||||||
|
* [#542](https://github.com/shlinkio/shlink-web-client/pull/542) Added ordering for short URLs to the query, so that it is consistent with the rest of the filtering params.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
|
||||||
|
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [3.4.2] - 2021-12-07
|
## [3.4.2] - 2021-12-07
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
35
package-lock.json
generated
35
package-lock.json
generated
|
@ -12,7 +12,7 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.2",
|
||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.0",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
|
@ -6470,11 +6470,11 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "0.21.1",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
|
||||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.10.0"
|
"follow-redirects": "^1.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
|
@ -13791,9 +13791,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.13.0",
|
"version": "1.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
|
||||||
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA==",
|
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
@ -13802,6 +13802,11 @@
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/for-in": {
|
"node_modules/for-in": {
|
||||||
|
@ -39831,11 +39836,11 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"axios": {
|
"axios": {
|
||||||
"version": "0.21.1",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.2.tgz",
|
||||||
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
|
"integrity": "sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"follow-redirects": "^1.10.0"
|
"follow-redirects": "^1.14.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"axobject-query": {
|
"axobject-query": {
|
||||||
|
@ -45629,9 +45634,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"follow-redirects": {
|
"follow-redirects": {
|
||||||
"version": "1.13.0",
|
"version": "1.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.5.tgz",
|
||||||
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
|
"integrity": "sha512-wtphSXy7d4/OR+MvIFbCVBDzZ5520qV8XfPklSN5QtxuMUJZ+b0Wnst1e1lCDocfzuCkHqj8k0FpZqO+UIaKNA=="
|
||||||
},
|
},
|
||||||
"for-in": {
|
"for-in": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
|
|
|
@ -27,7 +27,7 @@
|
||||||
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
"@fortawesome/free-regular-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
"@fortawesome/free-solid-svg-icons": "^5.15.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.14",
|
"@fortawesome/react-fontawesome": "^0.1.14",
|
||||||
"axios": "^0.21.1",
|
"axios": "^0.21.2",
|
||||||
"bootstrap": "^4.6.0",
|
"bootstrap": "^4.6.0",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.0",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
|
|
|
@ -11,31 +11,34 @@ import {
|
||||||
ShlinkVisits,
|
ShlinkVisits,
|
||||||
ShlinkVisitsParams,
|
ShlinkVisitsParams,
|
||||||
ShlinkShortUrlData,
|
ShlinkShortUrlData,
|
||||||
ShlinkDomain,
|
|
||||||
ShlinkDomainsResponse,
|
ShlinkDomainsResponse,
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
ShlinkEditDomainRedirects,
|
ShlinkEditDomainRedirects,
|
||||||
ShlinkDomainRedirects,
|
ShlinkDomainRedirects,
|
||||||
ShlinkShortUrlsListParams,
|
ShlinkShortUrlsListParams,
|
||||||
|
ShlinkShortUrlsListNormalizedParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
const buildShlinkBaseUrl = (url: string) => url ? `${url}/rest/v2` : '';
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
|
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
||||||
|
const { orderBy = {}, ...rest } = params;
|
||||||
|
|
||||||
|
return { ...rest, orderBy: orderToString(orderBy) };
|
||||||
|
};
|
||||||
|
|
||||||
export default class ShlinkApiClient {
|
export default class ShlinkApiClient {
|
||||||
private apiVersion: number;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly axios: AxiosInstance,
|
private readonly axios: AxiosInstance,
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
) {
|
) {
|
||||||
this.apiVersion = 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||||
|
@ -69,7 +72,10 @@ export default class ShlinkApiClient {
|
||||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
||||||
.then(() => {});
|
.then(() => {});
|
||||||
|
|
||||||
/* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead */
|
// eslint-disable-next-line valid-jsdoc
|
||||||
|
/**
|
||||||
|
* @deprecated. If using Shlink 2.6.0 or greater, use updateShortUrl instead
|
||||||
|
*/
|
||||||
public readonly updateShortUrlTags = async (
|
public readonly updateShortUrlTags = async (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
domain: OptionalString,
|
domain: OptionalString,
|
||||||
|
@ -107,43 +113,21 @@ export default class ShlinkApiClient {
|
||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
||||||
.then((resp) => resp.data);
|
.then((resp) => resp.data);
|
||||||
|
|
||||||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
||||||
|
|
||||||
public readonly editDomainRedirects = async (
|
public readonly editDomainRedirects = async (
|
||||||
domainRedirects: ShlinkEditDomainRedirects,
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
): Promise<ShlinkDomainRedirects> =>
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
||||||
try {
|
this.axios({
|
||||||
return await this.axios({
|
|
||||||
method,
|
method,
|
||||||
url: `${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}`,
|
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: rejectNilProps(query),
|
params: rejectNilProps(query),
|
||||||
data: body,
|
data: body,
|
||||||
paramsSerializer: stringifyQuery,
|
paramsSerializer: stringifyQuery,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
|
||||||
const { response } = e;
|
|
||||||
|
|
||||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
|
||||||
// when performed from the browser (due to the preflight request not returning a 2xx status.
|
|
||||||
// See https://github.com/shlinkio/shlink/issues/614), which will make the "response" prop not to be set here.
|
|
||||||
// The bug will be fixed on upcoming Shlink patches, but for other versions, we can consider this situation as
|
|
||||||
// if a request has been performed to a not supported API version.
|
|
||||||
const apiVersionIsNotSupported = !response;
|
|
||||||
|
|
||||||
// When the request is not invalid or we have already tried both API versions, throw the error and let the
|
|
||||||
// caller handle it
|
|
||||||
if (!apiVersionIsNotSupported || this.apiVersion === 2) {
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.apiVersion = this.apiVersion - 1;
|
|
||||||
|
|
||||||
return await this.performRequest(url, method, query, body);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { Visit } from '../../visits/types';
|
import { Visit } from '../../visits/types';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlMeta, ShortUrlsOrder } from '../../short-urls/data';
|
||||||
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
|
@ -84,6 +83,7 @@ export interface ShlinkDomain {
|
||||||
|
|
||||||
export interface ShlinkDomainsResponse {
|
export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.10
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkShortUrlsListParams {
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
@ -93,7 +93,11 @@ export interface ShlinkShortUrlsListParams {
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
orderBy?: OrderBy;
|
orderBy?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||||
|
orderBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
import './NoMenuLayout.scss';
|
||||||
|
|
||||||
const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
export const NoMenuLayout: FC = ({ children }) => <div className="no-menu-wrapper container-xl">{children}</div>;
|
||||||
|
|
||||||
export default NoMenuLayout;
|
|
||||||
|
|
|
@ -18,7 +18,8 @@ import { ConnectDecorator } from './types';
|
||||||
type LazyActionMap = Record<string, Function>;
|
type LazyActionMap = Record<string, Function>;
|
||||||
|
|
||||||
const bottle = new Bottle();
|
const bottle = new Bottle();
|
||||||
const { container } = bottle;
|
|
||||||
|
export const { container } = bottle;
|
||||||
|
|
||||||
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
const lazyService = <T extends Function, K>(container: IContainer, serviceName: string) =>
|
||||||
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
(...args: any[]) => (container[serviceName] as T)(...args) as K;
|
||||||
|
@ -44,5 +45,3 @@ provideUtilsServices(bottle);
|
||||||
provideMercureServices(bottle);
|
provideMercureServices(bottle);
|
||||||
provideSettingsServices(bottle, connect);
|
provideSettingsServices(bottle, connect);
|
||||||
provideDomainsServices(bottle, connect);
|
provideDomainsServices(bottle, connect);
|
||||||
|
|
||||||
export default container;
|
|
||||||
|
|
|
@ -2,6 +2,8 @@ import ReduxThunk from 'redux-thunk';
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
import { applyMiddleware, compose, createStore } from 'redux';
|
||||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import reducers from '../reducers';
|
||||||
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV !== 'production';
|
const isProduction = process.env.NODE_ENV !== 'production';
|
||||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
||||||
|
@ -12,9 +14,8 @@ const localStorageConfig: RLSOptions = {
|
||||||
namespaceSeparator: '.',
|
namespaceSeparator: '.',
|
||||||
debounce: 300,
|
debounce: 300,
|
||||||
};
|
};
|
||||||
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
const store = createStore(reducers, load(localStorageConfig), composeEnhancers(
|
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
||||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
||||||
));
|
));
|
||||||
|
|
||||||
export default store;
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import { Settings } from '../settings/reducers/settings';
|
||||||
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
import { ShortUrlCreation } from '../short-urls/reducers/shortUrlCreation';
|
||||||
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
import { ShortUrlDeletion } from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
import { ShortUrlEdition } from '../short-urls/reducers/shortUrlEdition';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { TagDeletion } from '../tags/reducers/tagDelete';
|
import { TagDeletion } from '../tags/reducers/tagDelete';
|
||||||
import { TagEdition } from '../tags/reducers/tagEdit';
|
import { TagEdition } from '../tags/reducers/tagEdit';
|
||||||
|
@ -20,7 +19,6 @@ export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsList;
|
shortUrlsList: ShortUrlsList;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreationResult: ShortUrlCreation;
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
|
|
|
@ -1,20 +1,26 @@
|
||||||
import { FC } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faBan as forbiddenIcon,
|
faBan as forbiddenIcon,
|
||||||
faCheck as defaultDomainIcon,
|
faDotCircle as defaultDomainIcon,
|
||||||
faEdit as editIcon,
|
faEdit as editIcon,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { OptionalString } from '../utils/utils';
|
import { OptionalString } from '../utils/utils';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
|
import { supportsDefaultDomainRedirectsEdition } from '../utils/helpers/features';
|
||||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||||
|
import { Domain } from './data';
|
||||||
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
|
|
||||||
interface DomainRowProps {
|
interface DomainRowProps {
|
||||||
domain: ShlinkDomain;
|
domain: Domain;
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||||
|
@ -30,13 +36,20 @@ const DefaultDomain: FC = () => (
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
export const DomainRow: FC<DomainRowProps> = (
|
||||||
|
{ domain, editDomainRedirects, checkDomainHealth, defaultRedirects, selectedServer },
|
||||||
|
) => {
|
||||||
const [ isOpen, toggle ] = useToggle();
|
const [ isOpen, toggle ] = useToggle();
|
||||||
const { domain: authority, isDefault, redirects } = domain;
|
const { domain: authority, isDefault, redirects, status } = domain;
|
||||||
|
const canEditDomain = !isDefault || supportsDefaultDomainRedirectsEdition(selectedServer);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkDomainHealth(domain.domain);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="responsive-table__row">
|
<tr className="responsive-table__row">
|
||||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
<td className="responsive-table__cell" data-th="Is default domain">{isDefault && <DefaultDomain />}</td>
|
||||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||||
|
@ -47,13 +60,16 @@ export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, def
|
||||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="responsive-table__cell text-lg-center" data-th="Status">
|
||||||
|
<DomainStatusIcon status={status} />
|
||||||
|
</td>
|
||||||
<td className="responsive-table__cell text-right">
|
<td className="responsive-table__cell text-right">
|
||||||
<span id={isDefault ? 'defaultDomainBtn' : undefined}>
|
<span id={!canEditDomain ? 'defaultDomainBtn' : undefined}>
|
||||||
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
<Button outline size="sm" disabled={!canEditDomain} onClick={!canEditDomain ? undefined : toggle}>
|
||||||
<FontAwesomeIcon fixedWidth icon={isDefault ? forbiddenIcon : editIcon} />
|
<FontAwesomeIcon fixedWidth icon={!canEditDomain ? forbiddenIcon : editIcon} />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
{isDefault && (
|
{!canEditDomain && (
|
||||||
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
|
<UncontrolledTooltip target="defaultDomainBtn" placement="left">
|
||||||
Redirects for default domain cannot be edited here.
|
Redirects for default domain cannot be edited here.
|
||||||
<br />
|
<br />
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { ShlinkDomainRedirects } from '../api/types';
|
||||||
|
import { SelectedServer } from '../servers/data';
|
||||||
import { DomainsList } from './reducers/domainsList';
|
import { DomainsList } from './reducers/domainsList';
|
||||||
import { DomainRow } from './DomainRow';
|
import { DomainRow } from './DomainRow';
|
||||||
|
|
||||||
|
@ -12,16 +13,18 @@ interface ManageDomainsProps {
|
||||||
listDomains: Function;
|
listDomains: Function;
|
||||||
filterDomains: (searchTerm: string) => void;
|
filterDomains: (searchTerm: string) => void;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||||
|
checkDomainHealth: (domain: string) => void;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '', '' ];
|
||||||
|
|
||||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
{ listDomains, domainsList, filterDomains, editDomainRedirects, checkDomainHealth, selectedServer },
|
||||||
) => {
|
) => {
|
||||||
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
const { filteredDomains: domains, defaultRedirects, loading, error, errorData } = domainsList;
|
||||||
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
const resolvedDefaultRedirects = defaultRedirects ?? domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listDomains();
|
listDomains();
|
||||||
|
@ -53,7 +56,9 @@ export const ManageDomains: FC<ManageDomainsProps> = (
|
||||||
key={domain.domain}
|
key={domain.domain}
|
||||||
domain={domain}
|
domain={domain}
|
||||||
editDomainRedirects={editDomainRedirects}
|
editDomainRedirects={editDomainRedirects}
|
||||||
defaultRedirects={defaultRedirects}
|
checkDomainHealth={checkDomainHealth}
|
||||||
|
defaultRedirects={resolvedDefaultRedirects}
|
||||||
|
selectedServer={selectedServer}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
7
src/domains/data/index.ts
Normal file
7
src/domains/data/index.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { ShlinkDomain } from '../../api/types';
|
||||||
|
|
||||||
|
export type DomainStatus = 'validating' | 'valid' | 'invalid';
|
||||||
|
|
||||||
|
export interface Domain extends ShlinkDomain {
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
62
src/domains/helpers/DomainStatusIcon.tsx
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import { FC, useEffect, useRef, useState } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { ExternalLink } from 'react-external-link';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faTimes as invalidIcon,
|
||||||
|
faCheck as checkIcon,
|
||||||
|
faCircleNotch as loadingStatusIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { MediaMatcher } from '../../utils/types';
|
||||||
|
import { DomainStatus } from '../data';
|
||||||
|
|
||||||
|
interface DomainStatusIconProps {
|
||||||
|
status: DomainStatus;
|
||||||
|
matchMedia?: MediaMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DomainStatusIcon: FC<DomainStatusIconProps> = ({ status, matchMedia = window.matchMedia }) => {
|
||||||
|
const ref = useRef<HTMLSpanElement>();
|
||||||
|
const matchesMobile = () => matchMedia('(max-width: 991px)').matches;
|
||||||
|
const [ isMobile, setIsMobile ] = useState<boolean>(matchesMobile());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = () => setIsMobile(matchesMobile());
|
||||||
|
|
||||||
|
window.addEventListener('resize', listener);
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (status === 'validating') {
|
||||||
|
return <FontAwesomeIcon fixedWidth icon={loadingStatusIcon} spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={(el: HTMLSpanElement) => {
|
||||||
|
ref.current = el;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'valid'
|
||||||
|
? <FontAwesomeIcon fixedWidth icon={checkIcon} className="text-muted" />
|
||||||
|
: <FontAwesomeIcon fixedWidth icon={invalidIcon} className="text-danger" />}
|
||||||
|
</span>
|
||||||
|
<UncontrolledTooltip
|
||||||
|
target={(() => ref.current) as any}
|
||||||
|
placement={isMobile ? 'top-start' : 'left'}
|
||||||
|
autohide={status === 'valid'}
|
||||||
|
>
|
||||||
|
{status === 'valid' ? 'Congratulations! This domain is properly configured.' : (
|
||||||
|
<span>
|
||||||
|
Oops! There is some missing configuration, and short URLs shared with this domain will not work.
|
||||||
|
<br />
|
||||||
|
Check the <ExternalLink href="https://slnk.to/multi-domain-docs">documentation</ExternalLink> in order to
|
||||||
|
find out what is missing.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,10 +1,13 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { Domain, DomainStatus } from '../data';
|
||||||
|
import { hasServerData } from '../../servers/data';
|
||||||
|
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
@ -12,24 +15,32 @@ export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||||
|
export const VALIDATE_DOMAIN = 'shlink/domainsList/VALIDATE_DOMAIN';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: ShlinkDomain[];
|
domains: Domain[];
|
||||||
filteredDomains: ShlinkDomain[];
|
filteredDomains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
export interface ListDomainsAction extends Action<string> {
|
||||||
domains: ShlinkDomain[];
|
domains: Domain[];
|
||||||
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterDomainsAction extends Action<string> {
|
interface FilterDomainsAction extends Action<string> {
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ValidateDomain extends Action<string> {
|
||||||
|
domain: string;
|
||||||
|
status: DomainStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: DomainsList = {
|
const initialState: DomainsList = {
|
||||||
domains: [],
|
domains: [],
|
||||||
filteredDomains: [],
|
filteredDomains: [],
|
||||||
|
@ -40,15 +51,20 @@ const initialState: DomainsList = {
|
||||||
export type DomainsCombinedAction = ListDomainsAction
|
export type DomainsCombinedAction = ListDomainsAction
|
||||||
& ApiErrorAction
|
& ApiErrorAction
|
||||||
& FilterDomainsAction
|
& FilterDomainsAction
|
||||||
& EditDomainRedirectsAction;
|
& EditDomainRedirectsAction
|
||||||
|
& ValidateDomain;
|
||||||
|
|
||||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||||
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
(d: Domain): Domain => d.domain !== domain ? d : { ...d, redirects };
|
||||||
|
|
||||||
|
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
|
(d: Domain): Domain => d.domain !== domain ? d : { ...d, status };
|
||||||
|
|
||||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
||||||
|
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
||||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||||
...state,
|
...state,
|
||||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||||
|
@ -58,6 +74,11 @@ export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
}),
|
}),
|
||||||
|
[VALIDATE_DOMAIN]: (state, { domain, status }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceStatusOnDomain(domain, status)),
|
||||||
|
}),
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||||
|
@ -68,12 +89,42 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||||
const { listDomains } = buildShlinkApiClient(getState);
|
const { listDomains } = buildShlinkApiClient(getState);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const domains = await listDomains();
|
const { domains, defaultRedirects } = await listDomains().then(({ data, defaultRedirects }) => ({
|
||||||
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
|
defaultRedirects,
|
||||||
|
}));
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains, defaultRedirects });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||||
|
|
||||||
|
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
||||||
|
dispatch: Dispatch,
|
||||||
|
getState: GetState,
|
||||||
|
) => {
|
||||||
|
const { selectedServer } = getState();
|
||||||
|
|
||||||
|
if (!hasServerData(selectedServer)) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { url, ...rest } = selectedServer;
|
||||||
|
const { health } = buildShlinkApiClient({
|
||||||
|
...rest,
|
||||||
|
url: replaceAuthorityFromUri(url, domain),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { status } = await health();
|
||||||
|
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
||||||
|
} catch (e) {
|
||||||
|
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
import { ManageDomains } from '../ManageDomains';
|
import { ManageDomains } from '../ManageDomains';
|
||||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
@ -12,14 +12,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||||
bottle.decorator('ManageDomains', connect(
|
bottle.decorator('ManageDomains', connect(
|
||||||
[ 'domainsList' ],
|
[ 'domainsList', 'selectedServer' ],
|
||||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
[ 'listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -2,8 +2,8 @@ import { render } from 'react-dom';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { homepage } from '../package.json';
|
import { homepage } from '../package.json';
|
||||||
import container from './container';
|
import { container } from './container';
|
||||||
import store from './container/store';
|
import { store } from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
|
|
|
@ -2,7 +2,6 @@ import { combineReducers } from 'redux';
|
||||||
import serversReducer from '../servers/reducers/servers';
|
import serversReducer from '../servers/reducers/servers';
|
||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import selectedServerReducer from '../servers/reducers/selectedServer';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
||||||
import shortUrlsListParamsReducer from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
||||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
||||||
|
@ -24,7 +23,6 @@ export default combineReducers<ShlinkState>({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: shortUrlsListReducer,
|
||||||
shortUrlsListParams: shortUrlsListParamsReducer,
|
|
||||||
shortUrlCreationResult: shortUrlCreationReducer,
|
shortUrlCreationResult: shortUrlCreationReducer,
|
||||||
shortUrlDeletion: shortUrlDeletionReducer,
|
shortUrlDeletion: shortUrlDeletionReducer,
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: shortUrlEditionReducer,
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
@import '../utils/base';
|
|
||||||
|
|
||||||
.create-server__label {
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,14 @@
|
||||||
import { FC } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout, useToggle } from '../utils/helpers/hooks';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerData, ServersMap, ServerWithId } from './data';
|
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import './CreateServer.scss';
|
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
|
@ -32,16 +32,30 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const [ isConfirmModalOpen, toggleConfirmModal ] = useToggle();
|
||||||
|
const [ serverData, setServerData ] = useState<ServerData | undefined>();
|
||||||
|
const save = () => {
|
||||||
|
if (!serverData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServer({ ...serverData, id });
|
||||||
push(`/server/${id}`);
|
push(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const serverExists = Object.values(servers).some(
|
||||||
|
({ url, apiKey }) => serverData?.url === url && serverData?.apiKey === apiKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
serverExists ? toggleConfirmModal() : save();
|
||||||
|
}, [ serverData ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={setServerData}>
|
||||||
{!hasServers &&
|
{!hasServers &&
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||||
|
@ -50,6 +64,13 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
|
|
||||||
{serversImported && <ImportResult type="success" />}
|
{serversImported && <ImportResult type="success" />}
|
||||||
{errorImporting && <ImportResult type="error" />}
|
{errorImporting && <ImportResult type="error" />}
|
||||||
|
|
||||||
|
<DuplicatedServersModal
|
||||||
|
isOpen={isConfirmModalOpen}
|
||||||
|
duplicatedServers={serverData ? [ serverData ] : []}
|
||||||
|
onDiscard={goBack}
|
||||||
|
onSave={save}
|
||||||
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { withSelectedServer } from './helpers/withSelectedServer';
|
import { withSelectedServer } from './helpers/withSelectedServer';
|
||||||
import { isServerWithId, ServerData } from './data';
|
import { isServerWithId, ServerData } from './data';
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Button, Row } from 'reactstrap';
|
||||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const Overview = (
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listShortUrls({ itemsPerPage: 5, orderBy: { dateCreated: 'DESC' } });
|
listShortUrls({ itemsPerPage: 5, orderBy: { field: 'dateCreated', dir: 'DESC' } });
|
||||||
listTags();
|
listTags();
|
||||||
loadVisitsOverview();
|
loadVisitsOverview();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
40
src/servers/helpers/DuplicatedServersModal.tsx
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { FC, Fragment } from 'react';
|
||||||
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
|
import { ServerData } from '../data';
|
||||||
|
|
||||||
|
interface DuplicatedServersModalProps {
|
||||||
|
duplicatedServers: ServerData[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
|
{ isOpen, duplicatedServers, onDiscard, onSave },
|
||||||
|
) => {
|
||||||
|
const hasMultipleServers = duplicatedServers.length > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal centered isOpen={isOpen}>
|
||||||
|
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
||||||
|
<ModalBody>
|
||||||
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
|
<ul>
|
||||||
|
{duplicatedServers.map(({ url, apiKey }, index) => !hasMultipleServers ? (
|
||||||
|
<Fragment key={index}>
|
||||||
|
<li>URL: <b>{url}</b></li>
|
||||||
|
<li>API key: <b>{apiKey}</b></li>
|
||||||
|
</Fragment>
|
||||||
|
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>)}
|
||||||
|
</ul>
|
||||||
|
<span>
|
||||||
|
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
||||||
|
</span>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicated' : 'Discard'}</Button>
|
||||||
|
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,10 +1,12 @@
|
||||||
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC, useState, useEffect } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { pipe } from 'ramda';
|
import { complement, pipe } from 'ramda';
|
||||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ServersImporter from '../services/ServersImporter';
|
import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { ServerData } from '../data';
|
import { ServersImporter } from '../services/ServersImporter';
|
||||||
|
import { ServerData, ServersMap } from '../data';
|
||||||
|
import { DuplicatedServersModal } from './DuplicatedServersModal';
|
||||||
import './ImportServersBtn.scss';
|
import './ImportServersBtn.scss';
|
||||||
|
|
||||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||||
|
@ -18,11 +20,16 @@ export interface ImportServersBtnProps {
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
createServers: (servers: ServerData[]) => void;
|
createServers: (servers: ServerData[]) => void;
|
||||||
|
servers: ServersMap;
|
||||||
fileRef: Ref<HTMLInputElement>;
|
fileRef: Ref<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serversFiltering = (servers: ServerData[]) =>
|
||||||
|
({ url, apiKey }: ServerData) => servers.some((server) => server.url === url && server.apiKey === apiKey);
|
||||||
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
|
servers,
|
||||||
fileRef,
|
fileRef,
|
||||||
children,
|
children,
|
||||||
onImport = () => {},
|
onImport = () => {},
|
||||||
|
@ -31,15 +38,37 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
const [ serversToCreate, setServersToCreate ] = useState<ServerData[] | undefined>();
|
||||||
|
const [ duplicatedServers, setDuplicatedServers ] = useState<ServerData[]>([]);
|
||||||
|
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||||
|
const create = pipe(createServers, onImport);
|
||||||
|
const createAllServers = pipe(() => create(serversToCreate ?? []), hideModal);
|
||||||
|
const createNonDuplicatedServers = pipe(
|
||||||
|
() => create((serversToCreate ?? []).filter(complement(serversFiltering(duplicatedServers)))),
|
||||||
|
hideModal,
|
||||||
|
);
|
||||||
|
const onFile = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
importServersFromFile(target.files?.[0])
|
importServersFromFile(target.files?.[0])
|
||||||
.then(pipe(createServers, onImport))
|
.then(setServersToCreate)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset input after processing file
|
// Reset input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null;
|
||||||
})
|
})
|
||||||
.catch(onImportError);
|
.catch(onImportError);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!serversToCreate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingServers = Object.values(servers);
|
||||||
|
const duplicatedServers = serversToCreate.filter(serversFiltering(existingServers));
|
||||||
|
const hasDuplicatedServers = !!duplicatedServers.length;
|
||||||
|
|
||||||
|
!hasDuplicatedServers ? create(serversToCreate) : setDuplicatedServers(duplicatedServers);
|
||||||
|
hasDuplicatedServers && showModal();
|
||||||
|
}, [ serversToCreate ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||||
|
@ -49,7 +78,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<Import
|
||||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
|
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onFile} />
|
||||||
|
|
||||||
|
<DuplicatedServersModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
duplicatedServers={duplicatedServers}
|
||||||
|
onDiscard={createNonDuplicatedServers}
|
||||||
|
onSave={createAllServers}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -4,7 +4,7 @@ import Message from '../../utils/Message';
|
||||||
import ServersListGroup from '../ServersListGroup';
|
import ServersListGroup from '../ServersListGroup';
|
||||||
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
import { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
import { isServerWithId, SelectedServer, ServersMap } from '../data';
|
||||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
import './ServerError.scss';
|
import './ServerError.scss';
|
||||||
|
|
||||||
interface ServerErrorProps {
|
interface ServerErrorProps {
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
|
@import '../../utils/base';
|
||||||
|
|
||||||
.server-form .form-group:last-child {
|
.server-form .form-group:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.server-form__label {
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
@ -12,7 +12,7 @@ interface ServerFormProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||||
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
<FormGroupContainer {...props} labelClassName="server-form__label" />;
|
||||||
|
|
||||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||||
const [ name, setName ] = useState('');
|
const [ name, setName ] = useState('');
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { FC, useEffect } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import Message from '../../utils/Message';
|
import Message from '../../utils/Message';
|
||||||
import { isNotFoundServer, SelectedServer } from '../data';
|
import { isNotFoundServer, SelectedServer } from '../data';
|
||||||
import NoMenuLayout from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
|
|
||||||
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
interface WithSelectedServerProps extends RouteComponentProps<{ serverId: string }> {
|
||||||
selectServer: (serverId: string) => void;
|
selectServer: (serverId: string) => void;
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { identity, memoizeWith, pipe } from 'ramda';
|
import { identity, memoizeWith, pipe } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { resetShortUrlParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
import { SelectedServer } from '../data';
|
import { SelectedServer } from '../data';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
|
@ -53,7 +52,6 @@ export const selectServer = (
|
||||||
getState: GetState,
|
getState: GetState,
|
||||||
) => {
|
) => {
|
||||||
dispatch(resetSelectedServer());
|
dispatch(resetSelectedServer());
|
||||||
dispatch(resetShortUrlParams());
|
|
||||||
|
|
||||||
const { servers } = getState();
|
const { servers } = getState();
|
||||||
const selectedServer = servers[serverId];
|
const selectedServer = servers[serverId];
|
||||||
|
|
|
@ -7,7 +7,7 @@ const validateServer = (server: any): server is ServerData =>
|
||||||
const validateServers = (servers: any): servers is ServerData[] =>
|
const validateServers = (servers: any): servers is ServerData[] =>
|
||||||
Array.isArray(servers) && servers.every(validateServer);
|
Array.isArray(servers) && servers.every(validateServer);
|
||||||
|
|
||||||
export default class ServersImporter {
|
export class ServersImporter {
|
||||||
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
public constructor(private readonly csvJson: CsvJson, private readonly fileReaderFactory: () => FileReader) {}
|
||||||
|
|
||||||
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
public readonly importServersFromFile = async (file?: File | null): Promise<ServerData[]> => {
|
||||||
|
|
|
@ -17,7 +17,7 @@ import { Overview } from '../Overview';
|
||||||
import { ManageServers } from '../ManageServers';
|
import { ManageServers } from '../ManageServers';
|
||||||
import { ManageServersRow } from '../ManageServersRow';
|
import { ManageServersRow } from '../ManageServersRow';
|
||||||
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||||
import ServersImporter from './ServersImporter';
|
import { ServersImporter } from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
|
@ -54,7 +54,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
bottle.serviceFactory('DeleteServerButton', DeleteServerButton, 'DeleteServerModal');
|
||||||
|
|
||||||
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
bottle.serviceFactory('ImportServersBtn', ImportServersBtn, 'ServersImporter');
|
||||||
bottle.decorator('ImportServersBtn', connect(null, [ 'createServers' ]));
|
bottle.decorator('ImportServersBtn', connect([ 'servers' ], [ 'createServers' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
bottle.serviceFactory('ForServerVersion', () => ForServerVersion);
|
||||||
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
bottle.decorator('ForServerVersion', connect([ 'selectedServer' ]));
|
||||||
|
|
|
@ -12,7 +12,7 @@ interface RealTimeUpdatesProps {
|
||||||
|
|
||||||
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
const intervalValue = (interval?: number) => !interval ? '' : `${interval}`;
|
||||||
|
|
||||||
const RealTimeUpdates = (
|
const RealTimeUpdatesSettings = (
|
||||||
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
{ settings: { realTimeUpdates }, toggleRealTimeUpdates, setRealTimeUpdatesInterval }: RealTimeUpdatesProps,
|
||||||
) => (
|
) => (
|
||||||
<SimpleCard title="Real-time updates" className="h-100">
|
<SimpleCard title="Real-time updates" className="h-100">
|
||||||
|
@ -50,4 +50,4 @@ const RealTimeUpdates = (
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default RealTimeUpdates;
|
export default RealTimeUpdatesSettings;
|
|
@ -1,13 +1,13 @@
|
||||||
import { FC, ReactNode } from 'react';
|
import { FC, ReactNode } from 'react';
|
||||||
import { Row } from 'reactstrap';
|
import { Row } from 'reactstrap';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
|
|
||||||
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
||||||
<>
|
<>
|
||||||
{items.map((child, index) => (
|
{items.map((child, index) => (
|
||||||
<Row key={index}>
|
<Row key={index}>
|
||||||
{child.map((subChild, subIndex) => (
|
{child.map((subChild, subIndex) => (
|
||||||
<div key={subIndex} className="col-lg-6 mb-3">
|
<div key={subIndex} className={`col-lg-${12 / child.length} mb-3`}>
|
||||||
{subChild}
|
{subChild}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -16,12 +16,20 @@ const SettingsSections: FC<{ items: ReactNode[][] }> = ({ items }) => (
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC, Visits: FC) => () => (
|
const Settings = (
|
||||||
|
RealTimeUpdates: FC,
|
||||||
|
ShortUrlCreation: FC,
|
||||||
|
ShortUrlsList: FC,
|
||||||
|
UserInterface: FC,
|
||||||
|
Visits: FC,
|
||||||
|
Tags: FC,
|
||||||
|
) => () => (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<SettingsSections
|
<SettingsSections
|
||||||
items={[
|
items={[
|
||||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||||
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
[ <ShortUrlCreation />, <ShortUrlsList /> ], // eslint-disable-line react/jsx-key
|
||||||
|
[ <Tags />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|
|
@ -3,11 +3,11 @@ import { DropdownItem, FormGroup } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||||
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
import { Settings, ShortUrlCreationSettings as ShortUrlsSettings, TagFilteringMode } from './reducers/settings';
|
||||||
|
|
||||||
interface ShortUrlCreationProps {
|
interface ShortUrlCreationProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
setShortUrlCreationSettings: (settings: ShortUrlsSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||||
|
@ -17,8 +17,8 @@ const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): R
|
||||||
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
||||||
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
||||||
|
|
||||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
export const ShortUrlCreationSettings: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
const shortUrlCreation: ShortUrlsSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||||
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||||
);
|
);
|
26
src/settings/ShortUrlsListSettings.tsx
Normal file
26
src/settings/ShortUrlsListSettings.tsx
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
|
import { SHORT_URLS_ORDERABLE_FIELDS } from '../short-urls/data';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { DEFAULT_SHORT_URLS_ORDERING, Settings, ShortUrlsListSettings as ShortUrlsSettings } from './reducers/settings';
|
||||||
|
|
||||||
|
interface ShortUrlsListProps {
|
||||||
|
settings: Settings;
|
||||||
|
setShortUrlsListSettings: (settings: ShortUrlsSettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShortUrlsListSettings: FC<ShortUrlsListProps> = (
|
||||||
|
{ settings: { shortUrlsList }, setShortUrlsListSettings },
|
||||||
|
) => (
|
||||||
|
<SimpleCard title="Short URLs list" className="h-100">
|
||||||
|
<FormGroup className="mb-0">
|
||||||
|
<label>Default ordering for short URLs list:</label>
|
||||||
|
<OrderingDropdown
|
||||||
|
items={SHORT_URLS_ORDERABLE_FIELDS}
|
||||||
|
order={shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING}
|
||||||
|
onChange={(field, dir) => setShortUrlsListSettings({ defaultOrdering: { field, dir } })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
35
src/settings/TagsSettings.tsx
Normal file
35
src/settings/TagsSettings.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||||
|
import { capitalize } from '../utils/utils';
|
||||||
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
|
import { TAGS_ORDERABLE_FIELDS } from '../tags/data/TagsListChildrenProps';
|
||||||
|
import { Settings, TagsSettings as TagsSettingsOptions } from './reducers/settings';
|
||||||
|
|
||||||
|
interface TagsProps {
|
||||||
|
settings: Settings;
|
||||||
|
setTagsSettings: (settings: TagsSettingsOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TagsSettings: FC<TagsProps> = ({ settings: { tags }, setTagsSettings }) => (
|
||||||
|
<SimpleCard title="Tags" className="h-100">
|
||||||
|
<FormGroup>
|
||||||
|
<label>Default display mode when managing tags:</label>
|
||||||
|
<TagsModeDropdown
|
||||||
|
mode={tags?.defaultMode ?? 'cards'}
|
||||||
|
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||||
|
onChange={(defaultMode) => setTagsSettings({ ...tags, defaultMode })}
|
||||||
|
/>
|
||||||
|
<small className="form-text text-muted">Tags will be displayed as <b>{tags?.defaultMode ?? 'cards'}</b>.</small>
|
||||||
|
</FormGroup>
|
||||||
|
<FormGroup className="mb-0">
|
||||||
|
<label>Default ordering for tags list:</label>
|
||||||
|
<OrderingDropdown
|
||||||
|
items={TAGS_ORDERABLE_FIELDS}
|
||||||
|
order={tags?.defaultOrdering ?? {}}
|
||||||
|
onChange={(field, dir) => setTagsSettings({ ...tags, defaultOrdering: { field, dir } })}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</SimpleCard>
|
||||||
|
);
|
|
@ -5,17 +5,15 @@ import { FormGroup } from 'reactstrap';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
|
||||||
import { capitalize } from '../utils/utils';
|
|
||||||
import { Settings, UiSettings } from './reducers/settings';
|
import { Settings, UiSettings } from './reducers/settings';
|
||||||
import './UserInterface.scss';
|
import './UserInterfaceSettings.scss';
|
||||||
|
|
||||||
interface UserInterfaceProps {
|
interface UserInterfaceProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setUiSettings: (settings: UiSettings) => void;
|
setUiSettings: (settings: UiSettings) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
export const UserInterfaceSettings: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||||
<SimpleCard title="User interface" className="h-100">
|
<SimpleCard title="User interface" className="h-100">
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||||
|
@ -31,14 +29,5 @@ export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiS
|
||||||
Use dark theme.
|
Use dark theme.
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
|
||||||
<label>Default display mode when managing tags:</label>
|
|
||||||
<TagsModeDropdown
|
|
||||||
mode={ui?.tagsMode ?? 'cards'}
|
|
||||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
|
||||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
|
||||||
/>
|
|
||||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
|
||||||
</FormGroup>
|
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
);
|
);
|
|
@ -2,14 +2,14 @@ import { FormGroup } from 'reactstrap';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
import { DateIntervalSelector } from '../utils/dates/DateIntervalSelector';
|
||||||
import { Settings, VisitsSettings } from './reducers/settings';
|
import { Settings, VisitsSettings as VisitsSettingsConfig } from './reducers/settings';
|
||||||
|
|
||||||
interface VisitsProps {
|
interface VisitsProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
setVisitsSettings: (settings: VisitsSettings) => void;
|
setVisitsSettings: (settings: VisitsSettingsConfig) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
export const VisitsSettings: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||||
<SimpleCard title="Visits" className="h-100">
|
<SimpleCard title="Visits" className="h-100">
|
||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
<label>Default interval to load on visits sections:</label>
|
<label>Default interval to load on visits sections:</label>
|
21
src/settings/helpers/index.ts
Normal file
21
src/settings/helpers/index.ts
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
|
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
|
||||||
|
if (!state.settings) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "last180Days" interval had a typo, with a lowercase d
|
||||||
|
if ((state.settings.visits?.defaultInterval as any) === 'last180days') {
|
||||||
|
state.settings.visits && (state.settings.visits.defaultInterval = 'last180Days');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The "tags display mode" option has been moved from "ui" to "tags"
|
||||||
|
state.settings.tags = {
|
||||||
|
...state.settings.tags,
|
||||||
|
defaultMode: state.settings.tags?.defaultMode ?? (state.settings.ui as any)?.tagsMode,
|
||||||
|
};
|
||||||
|
state.settings.ui && delete (state.settings.ui as any).tagsMode;
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
|
@ -4,9 +4,16 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { RecursivePartial } from '../../utils/utils';
|
import { RecursivePartial } from '../../utils/utils';
|
||||||
import { Theme } from '../../utils/theme';
|
import { Theme } from '../../utils/theme';
|
||||||
import { DateInterval } from '../../utils/dates/types';
|
import { DateInterval } from '../../utils/dates/types';
|
||||||
|
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||||
|
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
||||||
|
|
||||||
|
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||||
|
field: 'dateCreated',
|
||||||
|
dir: 'DESC',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
|
* Important! When adding new props in the main Settings interface or any of the nested props, they have to be set as
|
||||||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||||
|
@ -29,18 +36,28 @@ export type TagsMode = 'cards' | 'list';
|
||||||
|
|
||||||
export interface UiSettings {
|
export interface UiSettings {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
tagsMode?: TagsMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsSettings {
|
export interface VisitsSettings {
|
||||||
defaultInterval: DateInterval;
|
defaultInterval: DateInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TagsSettings {
|
||||||
|
defaultOrdering?: TagsOrder;
|
||||||
|
defaultMode?: TagsMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlsListSettings {
|
||||||
|
defaultOrdering?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
realTimeUpdates: RealTimeUpdatesSettings;
|
realTimeUpdates: RealTimeUpdatesSettings;
|
||||||
shortUrlCreation?: ShortUrlCreationSettings;
|
shortUrlCreation?: ShortUrlCreationSettings;
|
||||||
|
shortUrlsList?: ShortUrlsListSettings;
|
||||||
ui?: UiSettings;
|
ui?: UiSettings;
|
||||||
visits?: VisitsSettings;
|
visits?: VisitsSettings;
|
||||||
|
tags?: TagsSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: Settings = {
|
const initialState: Settings = {
|
||||||
|
@ -56,6 +73,9 @@ const initialState: Settings = {
|
||||||
visits: {
|
visits: {
|
||||||
defaultInterval: 'last30Days',
|
defaultInterval: 'last30Days',
|
||||||
},
|
},
|
||||||
|
shortUrlsList: {
|
||||||
|
defaultOrdering: DEFAULT_SHORT_URLS_ORDERING,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsAction = Action & Settings;
|
type SettingsAction = Action & Settings;
|
||||||
|
@ -81,6 +101,11 @@ export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings):
|
||||||
shortUrlCreation: settings,
|
shortUrlCreation: settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
|
||||||
|
type: SET_SETTINGS,
|
||||||
|
shortUrlsList: settings,
|
||||||
|
});
|
||||||
|
|
||||||
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
||||||
type: SET_SETTINGS,
|
type: SET_SETTINGS,
|
||||||
ui: settings,
|
ui: settings,
|
||||||
|
@ -90,3 +115,8 @@ export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsActi
|
||||||
type: SET_SETTINGS,
|
type: SET_SETTINGS,
|
||||||
visits: settings,
|
visits: settings,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
|
||||||
|
type: SET_SETTINGS,
|
||||||
|
tags: settings,
|
||||||
|
});
|
||||||
|
|
|
@ -1,46 +1,67 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import RealTimeUpdates from '../RealTimeUpdates';
|
import RealTimeUpdatesSettings from '../RealTimeUpdatesSettings';
|
||||||
import Settings from '../Settings';
|
import Settings from '../Settings';
|
||||||
import {
|
import {
|
||||||
setRealTimeUpdatesInterval,
|
setRealTimeUpdatesInterval,
|
||||||
setShortUrlCreationSettings,
|
setShortUrlCreationSettings,
|
||||||
|
setShortUrlsListSettings,
|
||||||
|
setTagsSettings,
|
||||||
setUiSettings,
|
setUiSettings,
|
||||||
setVisitsSettings,
|
setVisitsSettings,
|
||||||
toggleRealTimeUpdates,
|
toggleRealTimeUpdates,
|
||||||
} from '../reducers/settings';
|
} from '../reducers/settings';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||||
import { ShortUrlCreation } from '../ShortUrlCreation';
|
import { ShortUrlCreationSettings } from '../ShortUrlCreationSettings';
|
||||||
import { UserInterface } from '../UserInterface';
|
import { UserInterfaceSettings } from '../UserInterfaceSettings';
|
||||||
import { Visits } from '../Visits';
|
import { VisitsSettings } from '../VisitsSettings';
|
||||||
|
import { TagsSettings } from '../TagsSettings';
|
||||||
|
import { ShortUrlsListSettings } from '../ShortUrlsListSettings';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('Settings', Settings, 'RealTimeUpdates', 'ShortUrlCreation', 'UserInterface', 'Visits');
|
bottle.serviceFactory(
|
||||||
|
'Settings',
|
||||||
|
Settings,
|
||||||
|
'RealTimeUpdatesSettings',
|
||||||
|
'ShortUrlCreationSettings',
|
||||||
|
'ShortUrlsListSettings',
|
||||||
|
'UserInterfaceSettings',
|
||||||
|
'VisitsSettings',
|
||||||
|
'TagsSettings',
|
||||||
|
);
|
||||||
bottle.decorator('Settings', withoutSelectedServer);
|
bottle.decorator('Settings', withoutSelectedServer);
|
||||||
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
bottle.decorator('Settings', connect(null, [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('RealTimeUpdates', () => RealTimeUpdates);
|
bottle.serviceFactory('RealTimeUpdatesSettings', () => RealTimeUpdatesSettings);
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'RealTimeUpdates',
|
'RealTimeUpdatesSettings',
|
||||||
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
connect([ 'settings' ], [ 'toggleRealTimeUpdates', 'setRealTimeUpdatesInterval' ]),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlCreation', () => ShortUrlCreation);
|
bottle.serviceFactory('ShortUrlCreationSettings', () => ShortUrlCreationSettings);
|
||||||
bottle.decorator('ShortUrlCreation', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
bottle.decorator('ShortUrlCreationSettings', connect([ 'settings' ], [ 'setShortUrlCreationSettings' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('UserInterface', () => UserInterface);
|
bottle.serviceFactory('UserInterfaceSettings', () => UserInterfaceSettings);
|
||||||
bottle.decorator('UserInterface', connect([ 'settings' ], [ 'setUiSettings' ]));
|
bottle.decorator('UserInterfaceSettings', connect([ 'settings' ], [ 'setUiSettings' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('Visits', () => Visits);
|
bottle.serviceFactory('VisitsSettings', () => VisitsSettings);
|
||||||
bottle.decorator('Visits', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
bottle.decorator('VisitsSettings', connect([ 'settings' ], [ 'setVisitsSettings' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('TagsSettings', () => TagsSettings);
|
||||||
|
bottle.decorator('TagsSettings', connect([ 'settings' ], [ 'setTagsSettings' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ShortUrlsListSettings', () => ShortUrlsListSettings);
|
||||||
|
bottle.decorator('ShortUrlsListSettings', connect([ 'settings' ], [ 'setShortUrlsListSettings' ]));
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
bottle.serviceFactory('toggleRealTimeUpdates', () => toggleRealTimeUpdates);
|
||||||
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
bottle.serviceFactory('setRealTimeUpdatesInterval', () => setRealTimeUpdatesInterval);
|
||||||
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
|
bottle.serviceFactory('setShortUrlCreationSettings', () => setShortUrlCreationSettings);
|
||||||
|
bottle.serviceFactory('setShortUrlsListSettings', () => setShortUrlsListSettings);
|
||||||
bottle.serviceFactory('setUiSettings', () => setUiSettings);
|
bottle.serviceFactory('setUiSettings', () => setUiSettings);
|
||||||
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
|
bottle.serviceFactory('setVisitsSettings', () => setVisitsSettings);
|
||||||
|
bottle.serviceFactory('setTagsSettings', () => setTagsSettings);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -1,3 +0,0 @@
|
||||||
.search-bar__tags-icon {
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
|
@ -41,6 +41,7 @@ export const ShortUrlForm = (
|
||||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||||
const isEdit = mode === 'edit';
|
const isEdit = mode === 'edit';
|
||||||
|
const isBasicMode = mode === 'create-basic';
|
||||||
const hadTitleOriginally = hasValue(initialState.title);
|
const hadTitleOriginally = hasValue(initialState.title);
|
||||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||||
const reset = () => setShortUrlData(initialState);
|
const reset = () => setShortUrlData(initialState);
|
||||||
|
@ -66,8 +67,14 @@ export const ShortUrlForm = (
|
||||||
setShortUrlData(initialState);
|
setShortUrlData(initialState);
|
||||||
}, [ initialState ]);
|
}, [ initialState ]);
|
||||||
|
|
||||||
const renderOptionalInput = (id: NonDateFields, placeholder: string, type: InputType = 'text', props = {}) => (
|
const renderOptionalInput = (
|
||||||
<FormGroup>
|
id: NonDateFields,
|
||||||
|
placeholder: string,
|
||||||
|
type: InputType = 'text',
|
||||||
|
props = {},
|
||||||
|
fromGroupProps = {},
|
||||||
|
) => (
|
||||||
|
<FormGroup {...fromGroupProps}>
|
||||||
<Input
|
<Input
|
||||||
id={id}
|
id={id}
|
||||||
type={type}
|
type={type}
|
||||||
|
@ -101,10 +108,12 @@ export const ShortUrlForm = (
|
||||||
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
onChange={(e) => setShortUrlData({ ...shortUrlData, longUrl: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<Row>
|
||||||
<FormGroup>
|
{isBasicMode && renderOptionalInput('customSlug', 'Custom slug', 'text', { bsSize: 'lg' }, { className: 'col-lg-6' })}
|
||||||
|
<FormGroup className={isBasicMode ? 'col-lg-6' : 'col-12 mb-0'}>
|
||||||
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
<TagsSelector selectedTags={shortUrlData.tags ?? []} onChange={changeTags} />
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
</Row>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -118,8 +127,8 @@ export const ShortUrlForm = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="short-url-form" onSubmit={submit}>
|
<form className="short-url-form" onSubmit={submit}>
|
||||||
{mode === 'create-basic' && basicComponents}
|
{isBasicMode && basicComponents}
|
||||||
{mode !== 'create-basic' && (
|
{!isBasicMode && (
|
||||||
<>
|
<>
|
||||||
<SimpleCard title="Basic options" className="mb-3">
|
<SimpleCard title="Basic options" className="mb-3">
|
||||||
{basicComponents}
|
{basicComponents}
|
||||||
|
|
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
3
src/short-urls/ShortUrlsFilteringBar.scss
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.short-urls-filtering-bar__tags-icon {
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
|
@ -10,13 +10,13 @@ import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
import './SearchBar.scss';
|
import './ShortUrlsFilteringBar.scss';
|
||||||
|
|
||||||
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
export type ShortUrlsFilteringProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
const ShortUrlsFilteringBar = (colorGenerator: ColorGenerator) => (props: ShortUrlsFilteringProps) => {
|
||||||
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||||
const selectedTags = tags?.split(',') ?? [];
|
const selectedTags = tags?.split(',') ?? [];
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
|
@ -37,7 +37,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) =>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="short-urls-filtering-bar-container">
|
||||||
<SearchField initialValue={search} onChange={setSearch} />
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
|
@ -56,8 +56,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) =>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTags.length > 0 && (
|
{selectedTags.length > 0 && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="short-urls-filtering-bar__selected-tag mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="short-urls-filtering-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) =>
|
{selectedTags.map((tag) =>
|
||||||
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
|
@ -67,4 +67,4 @@ const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) =>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SearchBar;
|
export default ShortUrlsFilteringBar;
|
|
@ -1,77 +1,74 @@
|
||||||
import { head, keys, pipe, values } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
import { determineOrderDir, OrderDir } from '../utils/helpers/ordering';
|
||||||
import { getServerId, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { ShlinkShortUrlsListParams } from '../api/types';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
|
import { DEFAULT_SHORT_URLS_ORDERING, Settings } from '../settings/reducers/settings';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
import { ShortUrlsOrderableFields, SHORT_URLS_ORDERABLE_FIELDS } from './data';
|
||||||
|
|
||||||
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
settings: Settings;
|
||||||
resetShortUrlParams: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShortUrlsOrder = Order<OrderableFields>;
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, ShortUrlsFilteringBar: FC) => boundToMercureHub(({
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
|
||||||
shortUrlsListParams,
|
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
history,
|
history,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
|
settings,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
const serverId = getServerId(selectedServer);
|
const serverId = getServerId(selectedServer);
|
||||||
const { orderBy } = shortUrlsListParams;
|
const [{ tags, search, startDate, endDate, orderBy }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||||
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
const [ actualOrderBy, setActualOrderBy ] = useState(
|
||||||
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
// This separated state handling is needed to be able to fall back to settings value, but only once when loaded
|
||||||
dir: orderBy && head(values(orderBy)),
|
orderBy ?? settings.shortUrlsList?.defaultOrdering ?? DEFAULT_SHORT_URLS_ORDERING,
|
||||||
});
|
);
|
||||||
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
|
||||||
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
|
const handleOrderBy = (field?: ShortUrlsOrderableFields, dir?: OrderDir) => {
|
||||||
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
toFirstPage({ orderBy: { field, dir } });
|
||||||
{ ...shortUrlsListParams, ...extraParams },
|
setActualOrderBy({ field, dir });
|
||||||
);
|
|
||||||
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
|
||||||
setOrder({ field, dir });
|
|
||||||
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
|
||||||
};
|
};
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
const orderByColumn = (field: ShortUrlsOrderableFields) => () =>
|
||||||
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
handleOrderBy(field, determineOrderDir(field, actualOrderBy.field, actualOrderBy.dir));
|
||||||
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
const renderOrderIcon = (field: ShortUrlsOrderableFields) =>
|
||||||
|
<TableOrderIcon currentOrder={actualOrderBy} field={field} />;
|
||||||
const addTag = pipe(
|
const addTag = pipe(
|
||||||
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||||
(tags) => toFirstPage({ tags }),
|
(tags) => toFirstPage({ tags }),
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => resetShortUrlParams, []);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshList(
|
listShortUrls({
|
||||||
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
page: match.params.page,
|
||||||
);
|
searchTerm: search,
|
||||||
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
tags: selectedTags,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
orderBy: actualOrderBy,
|
||||||
|
});
|
||||||
|
}, [ match.params.page, search, selectedTags, startDate, endDate, actualOrderBy ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3"><SearchBar /></div>
|
<div className="mb-3"><ShortUrlsFilteringBar /></div>
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
<OrderingDropdown items={SHORT_URLS_ORDERABLE_FIELDS} order={actualOrderBy} onChange={handleOrderBy} />
|
||||||
</div>
|
</div>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-1">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
|
|
|
@ -5,12 +5,12 @@ import { SelectedServer } from '../servers/data';
|
||||||
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
import { supportsShortUrlTitle } from '../utils/helpers/features';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
import { ShortUrlsRowProps } from './helpers/ShortUrlsRow';
|
||||||
import { OrderableFields } from './reducers/shortUrlsListParams';
|
import { ShortUrlsOrderableFields } from './data';
|
||||||
import './ShortUrlsTable.scss';
|
import './ShortUrlsTable.scss';
|
||||||
|
|
||||||
export interface ShortUrlsTableProps {
|
export interface ShortUrlsTableProps {
|
||||||
orderByColumn?: (column: OrderableFields) => () => void;
|
orderByColumn?: (column: ShortUrlsOrderableFields) => () => void;
|
||||||
renderOrderIcon?: (column: OrderableFields) => ReactNode;
|
renderOrderIcon?: (column: ShortUrlsOrderableFields) => ReactNode;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Nullable, OptionalString } from '../../utils/utils';
|
import { Nullable, OptionalString } from '../../utils/utils';
|
||||||
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
export interface EditShortUrlData {
|
export interface EditShortUrlData {
|
||||||
longUrl?: string;
|
longUrl?: string;
|
||||||
|
@ -50,3 +51,15 @@ export interface ShortUrlIdentifier {
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
domain: OptionalString;
|
domain: OptionalString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||||
|
dateCreated: 'Created at',
|
||||||
|
shortCode: 'Short URL',
|
||||||
|
longUrl: 'Long URL',
|
||||||
|
title: 'Title',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShortUrlsOrderableFields = keyof typeof SHORT_URLS_ORDERABLE_FIELDS;
|
||||||
|
|
||||||
|
export type ShortUrlsOrder = Order<ShortUrlsOrderableFields>;
|
||||||
|
|
|
@ -1,27 +1,50 @@
|
||||||
import { RouteChildrenProps } from 'react-router-dom';
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { isEmpty } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||||
|
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||||
|
|
||||||
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||||
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
|
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||||
|
|
||||||
export interface ShortUrlListRouteParams {
|
export interface ShortUrlListRouteParams {
|
||||||
page: string;
|
page: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShortUrlsQuery {
|
interface ShortUrlsQueryCommon {
|
||||||
tags?: string;
|
tags?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
|
interface ShortUrlsQuery extends ShortUrlsQueryCommon {
|
||||||
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
|
orderBy?: string;
|
||||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
|
}
|
||||||
const evolvedQuery = stringifyQuery({ ...query, ...extra });
|
|
||||||
|
interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||||
|
orderBy?: ShortUrlsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShortUrlsQuery = (
|
||||||
|
{ history, location, match }: ServerIdRouteProps,
|
||||||
|
): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
|
const query = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||||
|
({ orderBy, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => !orderBy ? rest : {
|
||||||
|
...rest,
|
||||||
|
orderBy: stringToOrder<ShortUrlsOrderableFields>(orderBy),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
[ location.search ],
|
||||||
|
);
|
||||||
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||||
|
const { orderBy, ...mergedQuery } = { ...query, ...extra };
|
||||||
|
const normalizedQuery: ShortUrlsQuery = { ...mergedQuery, orderBy: orderBy && orderToString(orderBy) };
|
||||||
|
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||||
|
|
||||||
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
||||||
|
|
||||||
|
@ -25,7 +24,6 @@ export interface ShortUrlsList {
|
||||||
|
|
||||||
export interface ListShortUrlsAction extends Action<string> {
|
export interface ListShortUrlsAction extends Action<string> {
|
||||||
shortUrls: ShlinkShortUrlsResponse;
|
shortUrls: ShlinkShortUrlsResponse;
|
||||||
params: ShortUrlsListParams;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListShortUrlsCombinedAction = (
|
export type ListShortUrlsCombinedAction = (
|
||||||
|
@ -109,8 +107,8 @@ export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
try {
|
try {
|
||||||
const shortUrls = await listShortUrls(params);
|
const shortUrls = await listShortUrls(params);
|
||||||
|
|
||||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls, params });
|
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({ type: LIST_SHORT_URLS_ERROR, params });
|
dispatch({ type: LIST_SHORT_URLS_ERROR });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { OrderDir } from '../../utils/helpers/ordering';
|
|
||||||
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
|
||||||
|
|
||||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
|
||||||
|
|
||||||
export const SORTABLE_FIELDS = {
|
|
||||||
dateCreated: 'Created at',
|
|
||||||
shortCode: 'Short URL',
|
|
||||||
longUrl: 'Long URL',
|
|
||||||
title: 'Title',
|
|
||||||
visits: 'Visits',
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
|
||||||
|
|
||||||
export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
|
|
||||||
|
|
||||||
export interface ShortUrlsListParams {
|
|
||||||
page?: string;
|
|
||||||
itemsPerPage?: number;
|
|
||||||
orderBy?: OrderBy;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlsListParams = {
|
|
||||||
page: '1',
|
|
||||||
orderBy: { dateCreated: 'DESC' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default buildReducer<ShortUrlsListParams, ListShortUrlsAction>({
|
|
||||||
[LIST_SHORT_URLS]: (state, { params }) => ({ ...state, ...params }),
|
|
||||||
[RESET_SHORT_URL_PARAMS]: () => initialState,
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const resetShortUrlParams = buildActionCreator(RESET_SHORT_URL_PARAMS);
|
|
|
@ -1,5 +1,5 @@
|
||||||
import Bottle, { Decorator } from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import SearchBar from '../SearchBar';
|
import ShortUrlsFilteringBar from '../ShortUrlsFilteringBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
import ShortUrlsRowMenu from '../helpers/ShortUrlsRowMenu';
|
||||||
|
@ -9,7 +9,6 @@ import CreateShortUrlResult from '../helpers/CreateShortUrlResult';
|
||||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
import { listShortUrls } from '../reducers/shortUrlsList';
|
||||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
||||||
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
||||||
import { resetShortUrlParams } from '../reducers/shortUrlsListParams';
|
|
||||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
import { editShortUrl } from '../reducers/shortUrlEdition';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||||
|
@ -20,10 +19,10 @@ import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'ShortUrlsFilteringBar');
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
|
[ 'selectedServer', 'mercureInfo', 'shortUrlsList', 'settings' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
bottle.serviceFactory('ShortUrlsTable', ShortUrlsTable, 'ShortUrlsRow');
|
||||||
|
@ -51,12 +50,11 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('ShortUrlsFilteringBar', ShortUrlsFilteringBar, 'ColorGenerator');
|
||||||
bottle.decorator('SearchBar', withRouter);
|
bottle.decorator('ShortUrlsFilteringBar', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetShortUrlParams', () => resetShortUrlParams);
|
|
||||||
|
|
||||||
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
||||||
|
|
|
@ -10,9 +10,14 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||||
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import { OrderingDropdown } from '../utils/OrderingDropdown';
|
||||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
import {
|
||||||
|
TagsOrderableFields,
|
||||||
|
TAGS_ORDERABLE_FIELDS,
|
||||||
|
TagsListChildrenProps,
|
||||||
|
TagsOrder,
|
||||||
|
} from './data/TagsListChildrenProps';
|
||||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||||
import { NormalizedTag } from './data';
|
import { NormalizedTag } from './data';
|
||||||
import { TagsTableProps } from './TagsTable';
|
import { TagsTableProps } from './TagsTable';
|
||||||
|
@ -28,8 +33,8 @@ export interface TagsListProps {
|
||||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||||
) => {
|
) => {
|
||||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
const [ mode, setMode ] = useState<TagsMode>(settings.tags?.defaultMode ?? 'cards');
|
||||||
const [ order, setOrder ] = useState<TagsOrder>({});
|
const [ order, setOrder ] = useState<TagsOrder>(settings.tags?.defaultOrdering ?? {});
|
||||||
const resolveSortedTags = pipe(
|
const resolveSortedTags = pipe(
|
||||||
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||||
tag,
|
tag,
|
||||||
|
@ -55,7 +60,7 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderByColumn = (field: OrderableFields) => () => {
|
const orderByColumn = (field: TagsOrderableFields) => () => {
|
||||||
const dir = determineOrderDir(field, order.field, order.dir);
|
const dir = determineOrderDir(field, order.field, order.dir);
|
||||||
|
|
||||||
setOrder({ field: dir ? field : undefined, dir });
|
setOrder({ field: dir ? field : undefined, dir });
|
||||||
|
@ -88,7 +93,11 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableP
|
||||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-lg-6 mt-3 mt-lg-0">
|
<div className="col-lg-6 mt-3 mt-lg-0">
|
||||||
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
|
<OrderingDropdown
|
||||||
|
items={TAGS_ORDERABLE_FIELDS}
|
||||||
|
order={order}
|
||||||
|
onChange={(field, dir) => setOrder({ field, dir })}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
|
|
@ -6,12 +6,12 @@ import SimplePaginator from '../common/SimplePaginator';
|
||||||
import { useQueryState } from '../utils/helpers/hooks';
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
import { TagsOrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
import { TagsTableRowProps } from './TagsTableRow';
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
import './TagsTable.scss';
|
import './TagsTable.scss';
|
||||||
|
|
||||||
export interface TagsTableProps extends TagsListChildrenProps {
|
export interface TagsTableProps extends TagsListChildrenProps {
|
||||||
orderByColumn: (field: OrderableFields) => () => void;
|
orderByColumn: (field: TagsOrderableFields) => () => void;
|
||||||
currentOrder: TagsOrder;
|
currentOrder: TagsOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2,15 +2,15 @@ import { SelectedServer } from '../../servers/data';
|
||||||
import { Order } from '../../utils/helpers/ordering';
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
import { NormalizedTag } from './index';
|
import { NormalizedTag } from './index';
|
||||||
|
|
||||||
export const SORTABLE_FIELDS = {
|
export const TAGS_ORDERABLE_FIELDS = {
|
||||||
tag: 'Tag',
|
tag: 'Tag',
|
||||||
shortUrls: 'Short URLs',
|
shortUrls: 'Short URLs',
|
||||||
visits: 'Visits',
|
visits: 'Visits',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
export type TagsOrderableFields = keyof typeof TAGS_ORDERABLE_FIELDS;
|
||||||
|
|
||||||
export type TagsOrder = Order<OrderableFields>;
|
export type TagsOrder = Order<TagsOrderableFields>;
|
||||||
|
|
||||||
export interface TagsListChildrenProps {
|
export interface TagsListChildrenProps {
|
||||||
sortedTags: NormalizedTag[];
|
sortedTags: NormalizedTag[];
|
||||||
|
|
8
src/utils/OrderingDropdown.scss
Normal file
8
src/utils/OrderingDropdown.scss
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.ordering-dropdown__menu--link.ordering-dropdown__menu--link {
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ordering-dropdown__sort-icon {
|
||||||
|
margin: 3.5px 0 0;
|
||||||
|
float: right;
|
||||||
|
}
|
|
@ -4,9 +4,9 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
|
import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
|
||||||
import './SortingDropdown.scss';
|
import './OrderingDropdown.scss';
|
||||||
|
|
||||||
export interface SortingDropdownProps<T extends string = string> {
|
export interface OrderingDropdownProps<T extends string = string> {
|
||||||
items: Record<T, string>;
|
items: Record<T, string>;
|
||||||
order: Order<T>;
|
order: Order<T>;
|
||||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||||
|
@ -14,8 +14,8 @@ export interface SortingDropdownProps<T extends string = string> {
|
||||||
right?: boolean;
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SortingDropdown<T extends string = string>(
|
export function OrderingDropdown<T extends string = string>(
|
||||||
{ items, order, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
|
{ items, order, onChange, isButton = true, right = false }: OrderingDropdownProps<T>,
|
||||||
) {
|
) {
|
||||||
const handleItemClick = (fieldKey: T) => () => {
|
const handleItemClick = (fieldKey: T) => () => {
|
||||||
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
||||||
|
@ -36,7 +36,7 @@ export default function SortingDropdown<T extends string = string>(
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
right={right}
|
right={right}
|
||||||
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
|
className={classNames('w-100', { 'ordering-dropdown__menu--link': !isButton })}
|
||||||
>
|
>
|
||||||
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||||
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||||
|
@ -44,7 +44,7 @@ export default function SortingDropdown<T extends string = string>(
|
||||||
{order.field === fieldKey && (
|
{order.field === fieldKey && (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
|
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||||
className="sorting-dropdown__sort-icon"
|
className="ordering-dropdown__sort-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
|
@ -1,8 +0,0 @@
|
||||||
.sorting-dropdown__menu--link.sorting-dropdown__menu--link {
|
|
||||||
min-width: 11rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sorting-dropdown__sort-icon {
|
|
||||||
margin: 3.5px 0 0;
|
|
||||||
float: right;
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { DropdownBtn } from '../DropdownBtn';
|
import { DropdownBtn } from '../DropdownBtn';
|
||||||
|
import { useEffectExceptFirstTime } from '../helpers/hooks';
|
||||||
import {
|
import {
|
||||||
DateInterval,
|
DateInterval,
|
||||||
DateRange,
|
DateRange,
|
||||||
|
@ -17,10 +18,11 @@ export interface DateRangeSelectorProps {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onDatesChange: (dateRange: DateRange) => void;
|
onDatesChange: (dateRange: DateRange) => void;
|
||||||
defaultText: string;
|
defaultText: string;
|
||||||
|
updatable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateRangeSelector = (
|
export const DateRangeSelector = (
|
||||||
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
|
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
|
||||||
) => {
|
) => {
|
||||||
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
||||||
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
|
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
|
||||||
|
@ -37,6 +39,13 @@ export const DateRangeSelector = (
|
||||||
onDatesChange(intervalToDateRange(dateInterval));
|
onDatesChange(intervalToDateRange(dateInterval));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
updatable && useEffectExceptFirstTime(() => {
|
||||||
|
const isDateInterval = rangeIsInterval(initialDateRange);
|
||||||
|
|
||||||
|
isDateInterval && updateInterval(initialDateRange);
|
||||||
|
initialDateRange && !isDateInterval && updateDateRange(initialDateRange);
|
||||||
|
}, [ initialDateRange ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
||||||
<DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />
|
<DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />
|
||||||
|
|
|
@ -1,13 +1,13 @@
|
||||||
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||||
import { filter, isEmpty } from 'ramda';
|
import { cond, filter, isEmpty, T } from 'ramda';
|
||||||
import { formatInternational } from '../../helpers/date';
|
import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
startDate?: Date | null;
|
startDate?: Date | null;
|
||||||
endDate?: Date | null;
|
endDate?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
|
export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180Days' | 'last365Days';
|
||||||
|
|
||||||
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|| isEmpty(filter(Boolean, dateRange as any));
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
@ -21,7 +21,7 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||||
last7Days: 'Last 7 days',
|
last7Days: 'Last 7 days',
|
||||||
last30Days: 'Last 30 days',
|
last30Days: 'Last 30 days',
|
||||||
last90Days: 'Last 90 days',
|
last90Days: 'Last 90 days',
|
||||||
last180days: 'Last 180 days',
|
last180Days: 'Last 180 days',
|
||||||
last365Days: 'Last 365 days',
|
last365Days: 'Last 365 days',
|
||||||
all: undefined,
|
all: undefined,
|
||||||
};
|
};
|
||||||
|
@ -75,7 +75,7 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
return endingToday(startOfDaysAgo(30));
|
return endingToday(startOfDaysAgo(30));
|
||||||
case 'last90Days':
|
case 'last90Days':
|
||||||
return endingToday(startOfDaysAgo(90));
|
return endingToday(startOfDaysAgo(90));
|
||||||
case 'last180days':
|
case 'last180Days':
|
||||||
return endingToday(startOfDaysAgo(180));
|
return endingToday(startOfDaysAgo(180));
|
||||||
case 'last365Days':
|
case 'last365Days':
|
||||||
return endingToday(startOfDaysAgo(365));
|
return endingToday(startOfDaysAgo(365));
|
||||||
|
@ -83,3 +83,18 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
||||||
|
const theDate: Date = parseISO(date);
|
||||||
|
|
||||||
|
return cond<never, DateInterval>([
|
||||||
|
[ () => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days' ],
|
||||||
|
[ () => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days' ],
|
||||||
|
[ T, () => 'all' ],
|
||||||
|
])();
|
||||||
|
};
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
|
import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
|
||||||
import { OptionalString } from '../utils';
|
import { OptionalString } from '../utils';
|
||||||
|
|
||||||
type DateOrString = Date | string;
|
export type DateOrString = Date | string;
|
||||||
|
|
||||||
type NullableDate = DateOrString | null;
|
type NullableDate = DateOrString | null;
|
||||||
|
|
||||||
export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string';
|
export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string';
|
||||||
|
@ -22,20 +23,15 @@ export const formatInternational = formatDate();
|
||||||
|
|
||||||
export const parseDate = (date: string, format: string) => parse(date, format, new Date());
|
export const parseDate = (date: string, format: string) => parse(date, format, new Date());
|
||||||
|
|
||||||
const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
|
export const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
|
||||||
|
|
||||||
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
||||||
if (!start && end) {
|
try {
|
||||||
return isBefore(parseISO(date), parseISO(end));
|
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (start && !end) {
|
|
||||||
return isAfter(parseISO(date), parseISO(start));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (start && end) {
|
|
||||||
return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isBeforeOrEqual = (date: Date | number, dateToCompare: Date | number) =>
|
||||||
|
isEqual(date, dateToCompare) || isBefore(date, dateToCompare);
|
||||||
|
|
|
@ -23,3 +23,5 @@ export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.
|
||||||
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
||||||
|
|
||||||
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' });
|
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' });
|
||||||
|
|
||||||
|
export const supportsDefaultDomainRedirectsEdition = serverMatchesVersions({ minVersion: '2.10.0' });
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useRef } from 'react';
|
import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react';
|
||||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||||
import { parseQuery, stringifyQuery } from './query';
|
import { parseQuery, stringifyQuery } from './query';
|
||||||
|
|
||||||
|
@ -66,3 +66,12 @@ export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newV
|
||||||
|
|
||||||
return [ value, setValueWithLocation ];
|
return [ value, setValueWithLocation ];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => {
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
!isFirstLoad.current && callback();
|
||||||
|
isFirstLoad.current = false;
|
||||||
|
}, deps);
|
||||||
|
};
|
||||||
|
|
|
@ -30,3 +30,12 @@ export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof
|
||||||
|
|
||||||
return a[field] > b[field] ? greaterThan : smallerThan;
|
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const orderToString = <T>(order: Order<T>): string | undefined =>
|
||||||
|
order.dir ? `${order.field}-${order.dir}` : undefined;
|
||||||
|
|
||||||
|
export const stringToOrder = <T>(order: string): Order<T> => {
|
||||||
|
const [ field, dir ] = order.split('-') as [ T | undefined, OrderDir | undefined ];
|
||||||
|
|
||||||
|
return { field, dir };
|
||||||
|
};
|
||||||
|
|
7
src/utils/helpers/uri.ts
Normal file
7
src/utils/helpers/uri.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export const replaceAuthorityFromUri = (uri: string, newAuthority: string): string => {
|
||||||
|
const [ schema, rest ] = uri.split('://');
|
||||||
|
const [ , ...pathParts ] = rest.split('/');
|
||||||
|
const normalizedPath = pathParts.length ? `/${pathParts.join('/')}` : '';
|
||||||
|
|
||||||
|
return `${schema}://${newAuthority}${normalizedPath}`;
|
||||||
|
};
|
1
src/utils/types.ts
Normal file
1
src/utils/types.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type MediaMatcher = (query: string) => MediaQueryList;
|
|
@ -10,7 +10,11 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
|
|
||||||
export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps {
|
export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps {
|
||||||
getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void;
|
getOrphanVisits: (
|
||||||
|
params?: ShlinkVisitsParams,
|
||||||
|
orphanVisitsType?: OrphanVisitType,
|
||||||
|
doIntervalFallback?: boolean,
|
||||||
|
) => void;
|
||||||
orphanVisits: VisitsInfo;
|
orphanVisits: VisitsInfo;
|
||||||
cancelGetOrphanVisits: () => void;
|
cancelGetOrphanVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -25,7 +29,8 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: OrphanVisitsProps) => {
|
}: OrphanVisitsProps) => {
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
|
||||||
const loadVisits = (params: VisitsParams) => getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType);
|
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
||||||
|
getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitsStats
|
<VisitsStats
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
|
|
||||||
export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> {
|
export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> {
|
||||||
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
|
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
shortUrlVisits: ShortUrlVisitsState;
|
shortUrlVisits: ShortUrlVisitsState;
|
||||||
getShortUrlDetail: Function;
|
getShortUrlDetail: Function;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
|
@ -35,7 +35,8 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
|
||||||
}: ShortUrlVisitsProps) => {
|
}: ShortUrlVisitsProps) => {
|
||||||
const { shortCode } = params;
|
const { shortCode } = params;
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
const loadVisits = (params: VisitsParams) => getShortUrlVisits(shortCode, { ...toApiParams(params), domain });
|
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
||||||
|
getShortUrlVisits(shortCode, { ...toApiParams(params), domain }, doIntervalFallback);
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
|
||||||
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
|
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
|
||||||
visits,
|
visits,
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
|
|
||||||
export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> {
|
export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> {
|
||||||
getTagVisits: (tag: string, query?: ShlinkVisitsParams) => void;
|
getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
tagVisits: TagVisitsState;
|
tagVisits: TagVisitsState;
|
||||||
cancelGetTagVisits: () => void;
|
cancelGetTagVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,8 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: TagVisitsProps) => {
|
}: TagVisitsProps) => {
|
||||||
const { tag } = params;
|
const { tag } = params;
|
||||||
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, toApiParams(params));
|
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
||||||
|
getTagVisits(tag, toApiParams(params), doIntervalFallback);
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { isEmpty, propEq, values } from 'ramda';
|
import { isEmpty, propEq, values } from 'ramda';
|
||||||
import { useState, useEffect, useMemo, FC } from 'react';
|
import { useState, useEffect, useMemo, FC, useRef } from 'react';
|
||||||
import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
|
import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons';
|
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
@ -28,7 +28,7 @@ import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||||
import './VisitsStats.scss';
|
import './VisitsStats.scss';
|
||||||
|
|
||||||
export interface VisitsStatsProps {
|
export interface VisitsStatsProps {
|
||||||
getVisits: (params: VisitsParams) => void;
|
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
visitsInfo: VisitsInfo;
|
visitsInfo: VisitsInfo;
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
@ -81,19 +81,22 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
selectedServer,
|
selectedServer,
|
||||||
isOrphanVisits = false,
|
isOrphanVisits = false,
|
||||||
}) => {
|
}) => {
|
||||||
const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
|
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
||||||
|
const [ initialInterval, setInitialInterval ] = useState<DateInterval>(
|
||||||
|
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
||||||
|
);
|
||||||
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
|
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
|
||||||
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
|
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
|
||||||
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
|
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
|
||||||
const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({});
|
const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({});
|
||||||
const botsSupported = supportsBotVisits(selectedServer);
|
const botsSupported = supportsBotVisits(selectedServer);
|
||||||
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
const buildSectionUrl = (subPath?: string) => {
|
const buildSectionUrl = (subPath?: string) => {
|
||||||
const query = domain ? `?domain=${domain}` : '';
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
|
||||||
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
|
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
|
||||||
};
|
};
|
||||||
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
|
|
||||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
|
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
|
||||||
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
|
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
|
||||||
() => processStatsFromVisits(normalizedVisits),
|
() => processStatsFromVisits(normalizedVisits),
|
||||||
|
@ -121,8 +124,12 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
useEffect(() => cancelGetVisits, []);
|
useEffect(() => cancelGetVisits, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVisits({ dateRange, filter: visitsFilter });
|
getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current);
|
||||||
|
isFirstLoad.current = false;
|
||||||
}, [ dateRange, visitsFilter ]);
|
}, [ dateRange, visitsFilter ]);
|
||||||
|
useEffect(() => {
|
||||||
|
fallbackInterval && setInitialInterval(fallbackInterval);
|
||||||
|
}, [ fallbackInterval ]);
|
||||||
|
|
||||||
const renderVisitsContent = () => {
|
const renderVisitsContent = () => {
|
||||||
if (loadingLarge) {
|
if (loadingLarge) {
|
||||||
|
@ -272,6 +279,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
<div className="d-md-flex">
|
<div className="d-md-flex">
|
||||||
<div className="flex-fill">
|
<div className="flex-fill">
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
|
updatable
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
initialDateRange={initialInterval}
|
initialDateRange={initialInterval}
|
||||||
defaultText="All visits"
|
defaultText="All visits"
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { Time } from '../utils/Time';
|
import { Time } from '../utils/Time';
|
||||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
|
import { MediaMatcher } from '../utils/types';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||||
import './VisitsTable.scss';
|
import './VisitsTable.scss';
|
||||||
|
|
||||||
|
@ -19,7 +20,7 @@ export interface VisitsTableProps {
|
||||||
visits: NormalizedVisit[];
|
visits: NormalizedVisit[];
|
||||||
selectedVisits?: NormalizedVisit[];
|
selectedVisits?: NormalizedVisit[];
|
||||||
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
||||||
matchMedia?: (query: string) => MediaQueryList;
|
matchMedia?: MediaMatcher;
|
||||||
isOrphanVisits?: boolean;
|
isOrphanVisits?: boolean;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { rangeOf } from '../../utils/utils';
|
||||||
import { Order } from '../../utils/helpers/ordering';
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
import SimplePaginator from '../../common/SimplePaginator';
|
import SimplePaginator from '../../common/SimplePaginator';
|
||||||
import { roundTen } from '../../utils/helpers/numbers';
|
import { roundTen } from '../../utils/helpers/numbers';
|
||||||
import SortingDropdown from '../../utils/SortingDropdown';
|
import { OrderingDropdown } from '../../utils/OrderingDropdown';
|
||||||
import PaginationDropdown from '../../utils/PaginationDropdown';
|
import PaginationDropdown from '../../utils/PaginationDropdown';
|
||||||
import { Stats, StatsRow } from '../types';
|
import { Stats, StatsRow } from '../types';
|
||||||
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
|
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
|
||||||
|
@ -96,7 +96,7 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
<>
|
<>
|
||||||
{title}
|
{title}
|
||||||
<div className="float-right">
|
<div className="float-right">
|
||||||
<SortingDropdown
|
<OrderingDropdown
|
||||||
isButton={false}
|
isButton={false}
|
||||||
right
|
right
|
||||||
items={sortingItems}
|
items={sortingItems}
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
|
||||||
import { Visit } from '../types';
|
import { Visit } from '../types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { dateToMatchingInterval } from '../../utils/dates/types';
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5000;
|
const ITEMS_PER_PAGE = 5000;
|
||||||
const PARALLEL_REQUESTS_COUNT = 4;
|
const PARALLEL_REQUESTS_COUNT = 4;
|
||||||
|
@ -13,16 +14,19 @@ const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => cu
|
||||||
const calcProgress = (total: number, current: number): number => current * 100 / total;
|
const calcProgress = (total: number, current: number): number => current * 100 / total;
|
||||||
|
|
||||||
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
|
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
|
||||||
|
type LastVisitLoader = () => Promise<Visit | undefined>;
|
||||||
interface ActionMap {
|
interface ActionMap {
|
||||||
start: string;
|
start: string;
|
||||||
large: string;
|
large: string;
|
||||||
finish: string;
|
finish: string;
|
||||||
error: string;
|
error: string;
|
||||||
progress: string;
|
progress: string;
|
||||||
|
fallbackToInterval: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
|
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
|
||||||
visitsLoader: VisitsLoader,
|
visitsLoader: VisitsLoader,
|
||||||
|
lastVisitLoader: LastVisitLoader,
|
||||||
extraFinishActionData: Partial<T>,
|
extraFinishActionData: Partial<T>,
|
||||||
actionMap: ActionMap,
|
actionMap: ActionMap,
|
||||||
dispatch: Dispatch,
|
dispatch: Dispatch,
|
||||||
|
@ -69,10 +73,25 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const visits = await loadVisits();
|
const [ visits, lastVisit ] = await Promise.all([ loadVisits(), lastVisitLoader() ]);
|
||||||
|
|
||||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
dispatch(
|
||||||
|
!visits.length && lastVisit
|
||||||
|
? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) }
|
||||||
|
: { ...extraFinishActionData, visits, type: actionMap.finish },
|
||||||
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const lastVisitLoaderForLoader = (
|
||||||
|
doIntervalFallback: boolean,
|
||||||
|
loader: (params: ShlinkVisitsParams) => Promise<ShlinkVisits>,
|
||||||
|
): LastVisitLoader => {
|
||||||
|
if (!doIntervalFallback) {
|
||||||
|
return async () => Promise.resolve(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]);
|
||||||
|
};
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
import {
|
||||||
|
OrphanVisit,
|
||||||
|
OrphanVisitType,
|
||||||
|
Visit,
|
||||||
|
VisitsFallbackIntervalAction,
|
||||||
|
VisitsInfo,
|
||||||
|
VisitsLoadProgressChangedAction,
|
||||||
|
} from '../types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
|
@ -7,7 +14,7 @@ import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { isOrphanVisit } from '../types/helpers';
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
@ -17,6 +24,7 @@ export const GET_ORPHAN_VISITS = 'shlink/orphanVisits/GET_ORPHAN_VISITS';
|
||||||
export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE';
|
export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE';
|
||||||
export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL';
|
export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL';
|
||||||
export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED';
|
export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED';
|
||||||
|
export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface OrphanVisitsAction extends Action<string> {
|
export interface OrphanVisitsAction extends Action<string> {
|
||||||
|
@ -26,6 +34,7 @@ export interface OrphanVisitsAction extends Action<string> {
|
||||||
|
|
||||||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
|
& VisitsFallbackIntervalAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& ApiErrorAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
|
@ -41,10 +50,11 @@ const initialState: VisitsInfo = {
|
||||||
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
||||||
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }),
|
[GET_ORPHAN_VISITS]: (state, { visits, query }) => ({ ...state, visits, query, loading: false, error: false }),
|
||||||
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
|
[GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { visits, query = {} } = state;
|
const { visits, query = {} } = state;
|
||||||
const { startDate, endDate } = query;
|
const { startDate, endDate } = query;
|
||||||
|
@ -62,6 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
|
||||||
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
query: ShlinkVisitsParams = {},
|
query: ShlinkVisitsParams = {},
|
||||||
orphanVisitsType?: OrphanVisitType,
|
orphanVisitsType?: OrphanVisitType,
|
||||||
|
doIntervalFallback = false,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
const { getOrphanVisits } = buildShlinkApiClient(getState);
|
const { getOrphanVisits } = buildShlinkApiClient(getState);
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage })
|
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage })
|
||||||
|
@ -70,6 +81,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
|
|
||||||
return { ...result, data: visits };
|
return { ...result, data: visits };
|
||||||
});
|
});
|
||||||
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getOrphanVisits);
|
||||||
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
||||||
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
|
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
|
@ -78,9 +90,10 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
finish: GET_ORPHAN_VISITS,
|
finish: GET_ORPHAN_VISITS,
|
||||||
error: GET_ORPHAN_VISITS_ERROR,
|
error: GET_ORPHAN_VISITS_ERROR,
|
||||||
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
||||||
|
fallbackToInterval: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||||
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { ShortUrlIdentifier } from '../../short-urls/data';
|
import { ShortUrlIdentifier } from '../../short-urls/data';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
@ -8,7 +8,7 @@ import { GetState } from '../../container/types';
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
@ -18,6 +18,7 @@ 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 GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED';
|
export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED';
|
||||||
|
export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
||||||
|
@ -29,6 +30,7 @@ interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
||||||
|
|
||||||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
|
& VisitsFallbackIntervalAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& ApiErrorAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
|
@ -46,16 +48,19 @@ const initialState: ShortUrlVisits = {
|
||||||
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
||||||
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({
|
[GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({
|
||||||
...initialState,
|
...state,
|
||||||
visits,
|
visits,
|
||||||
shortCode,
|
shortCode,
|
||||||
domain,
|
domain,
|
||||||
query,
|
query,
|
||||||
|
loading: false,
|
||||||
|
error: false,
|
||||||
}),
|
}),
|
||||||
[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 }),
|
||||||
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
|
[GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { shortCode, domain, visits, query = {} } = state;
|
const { shortCode, domain, visits, query = {} } = state;
|
||||||
const { startDate, endDate } = query;
|
const { startDate, endDate } = query;
|
||||||
|
@ -73,12 +78,17 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
||||||
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
query: ShlinkVisitsParams = {},
|
query: ShlinkVisitsParams = {},
|
||||||
|
doIntervalFallback = false,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
const { getShortUrlVisits } = buildShlinkApiClient(getState);
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits(
|
const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits(
|
||||||
shortCode,
|
shortCode,
|
||||||
{ ...query, page, itemsPerPage },
|
{ ...query, page, itemsPerPage },
|
||||||
);
|
);
|
||||||
|
const lastVisitLoader = lastVisitLoaderForLoader(
|
||||||
|
doIntervalFallback,
|
||||||
|
async (params) => getShortUrlVisits(shortCode, { ...params, domain: query.domain }),
|
||||||
|
);
|
||||||
const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
|
const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
|
||||||
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
|
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
|
@ -87,9 +97,10 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||||
finish: GET_SHORT_URL_VISITS,
|
finish: GET_SHORT_URL_VISITS,
|
||||||
error: GET_SHORT_URL_VISITS_ERROR,
|
error: GET_SHORT_URL_VISITS_ERROR,
|
||||||
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
|
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
|
||||||
|
fallbackToInterval: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL);
|
export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL);
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { Action, Dispatch } from 'redux';
|
||||||
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
/* eslint-disable padding-line-between-statements */
|
/* eslint-disable padding-line-between-statements */
|
||||||
|
@ -16,6 +16,7 @@ export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS';
|
||||||
export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE';
|
export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE';
|
||||||
export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL';
|
export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL';
|
||||||
export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED';
|
export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED';
|
||||||
|
export const GET_TAG_VISITS_FALLBACK_TO_INTERVAL = 'shlink/tagVisits/GET_TAG_VISITS_FALLBACK_TO_INTERVAL';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface TagVisits extends VisitsInfo {
|
export interface TagVisits extends VisitsInfo {
|
||||||
|
@ -30,6 +31,7 @@ export interface TagVisitsAction extends Action<string> {
|
||||||
|
|
||||||
type TagsVisitsCombinedAction = TagVisitsAction
|
type TagsVisitsCombinedAction = TagVisitsAction
|
||||||
& VisitsLoadProgressChangedAction
|
& VisitsLoadProgressChangedAction
|
||||||
|
& VisitsFallbackIntervalAction
|
||||||
& CreateVisitsAction
|
& CreateVisitsAction
|
||||||
& ApiErrorAction;
|
& ApiErrorAction;
|
||||||
|
|
||||||
|
@ -46,10 +48,11 @@ const initialState: TagVisits = {
|
||||||
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
||||||
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_TAG_VISITS]: (_, { visits, tag, query }) => ({ ...initialState, visits, tag, query }),
|
[GET_TAG_VISITS]: (state, { visits, tag, query }) => ({ ...state, visits, tag, query, loading: false, error: false }),
|
||||||
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
|
[GET_TAG_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { tag, visits, query = {} } = state;
|
const { tag, visits, query = {} } = state;
|
||||||
const { startDate, endDate } = query;
|
const { startDate, endDate } = query;
|
||||||
|
@ -64,12 +67,14 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
||||||
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
tag: string,
|
tag: string,
|
||||||
query: ShlinkVisitsParams = {},
|
query: ShlinkVisitsParams = {},
|
||||||
|
doIntervalFallback = false,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
const { getTagVisits } = buildShlinkApiClient(getState);
|
const { getTagVisits } = buildShlinkApiClient(getState);
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits(
|
const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits(
|
||||||
tag,
|
tag,
|
||||||
{ ...query, page, itemsPerPage },
|
{ ...query, page, itemsPerPage },
|
||||||
);
|
);
|
||||||
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getTagVisits(tag, params));
|
||||||
const shouldCancel = () => getState().tagVisits.cancelLoad;
|
const shouldCancel = () => getState().tagVisits.cancelLoad;
|
||||||
const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
|
const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
|
@ -78,9 +83,10 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
finish: GET_TAG_VISITS,
|
finish: GET_TAG_VISITS,
|
||||||
error: GET_TAG_VISITS_ERROR,
|
error: GET_TAG_VISITS_ERROR,
|
||||||
progress: GET_TAG_VISITS_PROGRESS_CHANGED,
|
progress: GET_TAG_VISITS_PROGRESS_CHANGED,
|
||||||
|
fallbackToInterval: GET_TAG_VISITS_FALLBACK_TO_INTERVAL,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL);
|
export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { ShortUrl } from '../../short-urls/data';
|
import { ShortUrl } from '../../short-urls/data';
|
||||||
import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types';
|
import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types';
|
||||||
import { DateRange } from '../../utils/dates/types';
|
import { DateInterval, DateRange } from '../../utils/dates/types';
|
||||||
|
|
||||||
export interface VisitsInfo {
|
export interface VisitsInfo {
|
||||||
visits: Visit[];
|
visits: Visit[];
|
||||||
|
@ -12,12 +12,17 @@ export interface VisitsInfo {
|
||||||
progress: number;
|
progress: number;
|
||||||
cancelLoad: boolean;
|
cancelLoad: boolean;
|
||||||
query?: ShlinkVisitsParams;
|
query?: ShlinkVisitsParams;
|
||||||
|
fallbackInterval?: DateInterval;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsLoadProgressChangedAction extends Action<string> {
|
export interface VisitsLoadProgressChangedAction extends Action<string> {
|
||||||
progress: number;
|
progress: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface VisitsFallbackIntervalAction extends Action<string> {
|
||||||
|
fallbackInterval: DateInterval;
|
||||||
|
}
|
||||||
|
|
||||||
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||||
|
|
||||||
interface VisitLocation {
|
interface VisitLocation {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Mock } from 'ts-mockery';
|
||||||
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
import { OptionalString } from '../../../src/utils/utils';
|
import { OptionalString } from '../../../src/utils/utils';
|
||||||
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
|
import { ShlinkDomain, ShlinkVisitsOverview } from '../../../src/api/types';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl, ShortUrlsOrder } from '../../../src/short-urls/data';
|
||||||
import { Visit } from '../../../src/visits/types';
|
import { Visit } from '../../../src/visits/types';
|
||||||
|
|
||||||
describe('ShlinkApiClient', () => {
|
describe('ShlinkApiClient', () => {
|
||||||
|
@ -17,9 +17,9 @@ describe('ShlinkApiClient', () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
describe('listShortUrls', () => {
|
describe('listShortUrls', () => {
|
||||||
it('properly returns short URLs list', async () => {
|
|
||||||
const expectedList = [ 'foo', 'bar' ];
|
const expectedList = [ 'foo', 'bar' ];
|
||||||
|
|
||||||
|
it('properly returns short URLs list', async () => {
|
||||||
const { listShortUrls } = createApiClient({
|
const { listShortUrls } = createApiClient({
|
||||||
data: {
|
data: {
|
||||||
shortUrls: expectedList,
|
shortUrls: expectedList,
|
||||||
|
@ -30,6 +30,23 @@ describe('ShlinkApiClient', () => {
|
||||||
|
|
||||||
expect(expectedList).toEqual(actualList);
|
expect(expectedList).toEqual(actualList);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ { field: 'visits', dir: 'DESC' } as ShortUrlsOrder, 'visits-DESC' ],
|
||||||
|
[ { field: 'longUrl', dir: 'ASC' } as ShortUrlsOrder, 'longUrl-ASC' ],
|
||||||
|
[ { field: 'longUrl', dir: undefined } as ShortUrlsOrder, undefined ],
|
||||||
|
])('parses orderBy in params', async (orderBy, expectedOrderBy) => {
|
||||||
|
const axiosSpy = createAxiosMock({
|
||||||
|
data: expectedList,
|
||||||
|
});
|
||||||
|
const { listShortUrls } = new ShlinkApiClient(axiosSpy, '', '');
|
||||||
|
|
||||||
|
await listShortUrls({ orderBy });
|
||||||
|
|
||||||
|
expect(axiosSpy).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
params: { orderBy: expectedOrderBy },
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createShortUrl', () => {
|
describe('createShortUrl', () => {
|
||||||
|
@ -256,10 +273,8 @@ describe('ShlinkApiClient', () => {
|
||||||
|
|
||||||
describe('listDomains', () => {
|
describe('listDomains', () => {
|
||||||
it('returns domains', async () => {
|
it('returns domains', async () => {
|
||||||
const expectedData = [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ];
|
const expectedData = { data: [ Mock.all<ShlinkDomain>(), Mock.all<ShlinkDomain>() ] };
|
||||||
const resp = {
|
const resp = { domains: expectedData };
|
||||||
domains: { data: expectedData },
|
|
||||||
};
|
|
||||||
const axiosSpy = createAxiosMock({ data: resp });
|
const axiosSpy = createAxiosMock({ data: resp });
|
||||||
const { listDomains } = new ShlinkApiClient(axiosSpy, '', '');
|
const { listDomains } = new ShlinkApiClient(axiosSpy, '', '');
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { Route } from 'react-router-dom';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import createMenuLayout from '../../src/common/MenuLayout';
|
import createMenuLayout from '../../src/common/MenuLayout';
|
||||||
import { NonReachableServer, NotFoundServer, ReachableServer, SelectedServer } from '../../src/servers/data';
|
import { NonReachableServer, NotFoundServer, ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import NoMenuLayout from '../../src/common/NoMenuLayout';
|
import { NoMenuLayout } from '../../src/common/NoMenuLayout';
|
||||||
import { SemVer } from '../../src/utils/helpers/version';
|
import { SemVer } from '../../src/utils/helpers/version';
|
||||||
|
|
||||||
describe('<MenuLayout />', () => {
|
describe('<MenuLayout />', () => {
|
||||||
|
|
|
@ -3,13 +3,22 @@ import { Mock } from 'ts-mockery';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faBan as forbiddenIcon, faEdit as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../src/api/types';
|
import { ShlinkDomainRedirects } from '../../src/api/types';
|
||||||
import { DomainRow } from '../../src/domains/DomainRow';
|
import { DomainRow } from '../../src/domains/DomainRow';
|
||||||
|
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
|
import { Domain } from '../../src/domains/data';
|
||||||
|
|
||||||
describe('<DomainRow />', () => {
|
describe('<DomainRow />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (domain: ShlinkDomain) => {
|
const createWrapper = (domain: Domain, selectedServer = Mock.all<SelectedServer>()) => {
|
||||||
wrapper = shallow(<DomainRow domain={domain} editDomainRedirects={jest.fn()} />);
|
wrapper = shallow(
|
||||||
|
<DomainRow
|
||||||
|
domain={domain}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
editDomainRedirects={jest.fn()}
|
||||||
|
checkDomainHealth={jest.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
@ -17,28 +26,60 @@ describe('<DomainRow />', () => {
|
||||||
afterEach(() => wrapper?.unmount());
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: true }), 1, 'defaultDomainBtn' ],
|
[ Mock.of<Domain>({ domain: '', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||||
[ Mock.of<ShlinkDomain>({ domain: '', isDefault: false }), 0, undefined ],
|
[ Mock.of<Domain>({ domain: '', isDefault: false }), undefined, 0, 0, undefined ],
|
||||||
[ Mock.of<ShlinkDomain>({ domain: 'foo.com', isDefault: true }), 1, 'defaultDomainBtn' ],
|
[ Mock.of<Domain>({ domain: 'foo.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||||
[ Mock.of<ShlinkDomain>({ domain: 'foo.bar.com', isDefault: true }), 1, 'defaultDomainBtn' ],
|
[ Mock.of<Domain>({ domain: 'foo.bar.com', isDefault: true }), undefined, 1, 1, 'defaultDomainBtn' ],
|
||||||
[ Mock.of<ShlinkDomain>({ domain: 'foo.baz', isDefault: false }), 0, undefined ],
|
[ Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }), undefined, 0, 0, undefined ],
|
||||||
])('shows proper components based on the fact that provided domain is default or not', (
|
[
|
||||||
|
Mock.of<Domain>({ domain: 'foo.baz', isDefault: true }),
|
||||||
|
Mock.of<ReachableServer>({ version: '2.10.0' }),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<Domain>({ domain: 'foo.baz', isDefault: true }),
|
||||||
|
Mock.of<ReachableServer>({ version: '2.9.0' }),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
'defaultDomainBtn',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
|
||||||
|
Mock.of<ReachableServer>({ version: '2.9.0' }),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Mock.of<Domain>({ domain: 'foo.baz', isDefault: false }),
|
||||||
|
Mock.of<ReachableServer>({ version: '2.10.0' }),
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
],
|
||||||
|
])('shows proper components based on provided domain and selectedServer', (
|
||||||
domain,
|
domain,
|
||||||
expectedComps,
|
selectedServer,
|
||||||
|
expectedDefaultDomainIcons,
|
||||||
|
expectedDisabledComps,
|
||||||
expectedDomainId,
|
expectedDomainId,
|
||||||
) => {
|
) => {
|
||||||
const wrapper = createWrapper(domain);
|
const wrapper = createWrapper(domain, selectedServer);
|
||||||
const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain');
|
const defaultDomainComp = wrapper.find('td').first().find('DefaultDomain');
|
||||||
|
const disabledBtn = wrapper.find(Button).findWhere((btn) => !!btn.prop('disabled'));
|
||||||
const tooltip = wrapper.find(UncontrolledTooltip);
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
const button = wrapper.find(Button);
|
const button = wrapper.find(Button);
|
||||||
const icon = wrapper.find(FontAwesomeIcon);
|
const icon = wrapper.find(FontAwesomeIcon);
|
||||||
|
|
||||||
expect(defaultDomainComp).toHaveLength(expectedComps);
|
expect(defaultDomainComp).toHaveLength(expectedDefaultDomainIcons);
|
||||||
expect(button.prop('disabled')).toEqual(domain.isDefault);
|
expect(disabledBtn).toHaveLength(expectedDisabledComps);
|
||||||
expect(icon.prop('icon')).toEqual(domain.isDefault ? forbiddenIcon : editIcon);
|
expect(button.prop('disabled')).toEqual(expectedDisabledComps > 0);
|
||||||
expect(tooltip).toHaveLength(expectedComps);
|
expect(icon.prop('icon')).toEqual(expectedDisabledComps > 0 ? forbiddenIcon : editIcon);
|
||||||
|
expect(tooltip).toHaveLength(expectedDisabledComps);
|
||||||
|
|
||||||
if (expectedComps > 0) {
|
if (expectedDisabledComps > 0) {
|
||||||
expect(tooltip.prop('target')).toEqual(expectedDomainId);
|
expect(tooltip.prop('target')).toEqual(expectedDomainId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -56,7 +97,7 @@ describe('<DomainRow />', () => {
|
||||||
0,
|
0,
|
||||||
],
|
],
|
||||||
])('shows expected redirects', (redirects, expectedNoRedirects) => {
|
])('shows expected redirects', (redirects, expectedNoRedirects) => {
|
||||||
const wrapper = createWrapper(Mock.of<ShlinkDomain>({ domain: '', isDefault: true, redirects }));
|
const wrapper = createWrapper(Mock.of<Domain>({ domain: '', isDefault: true, redirects }));
|
||||||
const noRedirects = wrapper.find('Nr');
|
const noRedirects = wrapper.find('Nr');
|
||||||
const cells = wrapper.find('td');
|
const cells = wrapper.find('td');
|
||||||
|
|
||||||
|
|
|
@ -8,19 +8,21 @@ import SearchField from '../../src/utils/SearchField';
|
||||||
import { ProblemDetailsError, ShlinkDomain } from '../../src/api/types';
|
import { ProblemDetailsError, ShlinkDomain } from '../../src/api/types';
|
||||||
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
|
import { ShlinkApiError } from '../../src/api/ShlinkApiError';
|
||||||
import { DomainRow } from '../../src/domains/DomainRow';
|
import { DomainRow } from '../../src/domains/DomainRow';
|
||||||
|
import { SelectedServer } from '../../src/servers/data';
|
||||||
|
|
||||||
describe('<ManageDomains />', () => {
|
describe('<ManageDomains />', () => {
|
||||||
const listDomains = jest.fn();
|
const listDomains = jest.fn();
|
||||||
const filterDomains = jest.fn();
|
const filterDomains = jest.fn();
|
||||||
const editDomainRedirects = jest.fn();
|
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (domainsList: DomainsList) => {
|
const createWrapper = (domainsList: DomainsList) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<ManageDomains
|
<ManageDomains
|
||||||
listDomains={listDomains}
|
listDomains={listDomains}
|
||||||
filterDomains={filterDomains}
|
filterDomains={filterDomains}
|
||||||
editDomainRedirects={editDomainRedirects}
|
editDomainRedirects={jest.fn()}
|
||||||
|
checkDomainHealth={jest.fn()}
|
||||||
domainsList={domainsList}
|
domainsList={domainsList}
|
||||||
|
selectedServer={Mock.all<SelectedServer>()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -75,7 +77,7 @@ describe('<ManageDomains />', () => {
|
||||||
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
const wrapper = createWrapper(Mock.of<DomainsList>({ loading: false, error: false, filteredDomains: [] }));
|
||||||
const headerCells = wrapper.find('th');
|
const headerCells = wrapper.find('th');
|
||||||
|
|
||||||
expect(headerCells).toHaveLength(6);
|
expect(headerCells).toHaveLength(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('one row when list of domains is empty', () => {
|
it('one row when list of domains is empty', () => {
|
||||||
|
|
73
test/domains/helpers/DomainStatusIcon.test.tsx
Normal file
73
test/domains/helpers/DomainStatusIcon.test.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { faTimes, faCheck, faCircleNotch } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { DomainStatus } from '../../../src/domains/data';
|
||||||
|
import { DomainStatusIcon } from '../../../src/domains/helpers/DomainStatusIcon';
|
||||||
|
|
||||||
|
describe('<DomainStatusIcon />', () => {
|
||||||
|
const matchMedia = jest.fn().mockReturnValue(Mock.of<MediaQueryList>({ matches: false }));
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (status: DomainStatus) => {
|
||||||
|
wrapper = shallow(<DomainStatusIcon status={status} matchMedia={matchMedia} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it('renders loading icon when status is "validating"', () => {
|
||||||
|
const wrapper = createWrapper('validating');
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
const faIcon = wrapper.find(FontAwesomeIcon);
|
||||||
|
|
||||||
|
expect(tooltip).toHaveLength(0);
|
||||||
|
expect(faIcon).toHaveLength(1);
|
||||||
|
expect(faIcon.prop('icon')).toEqual(faCircleNotch);
|
||||||
|
expect(faIcon.prop('spin')).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
'invalid' as DomainStatus,
|
||||||
|
faTimes,
|
||||||
|
'Oops! There is some missing configuration, and short URLs shared with this domain will not work.',
|
||||||
|
],
|
||||||
|
[ 'valid' as DomainStatus, faCheck, 'Congratulations! This domain is properly configured.' ],
|
||||||
|
])('renders expected icon and tooltip when status is not validating', (status, expectedIcon, expectedText) => {
|
||||||
|
const wrapper = createWrapper(status);
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
const faIcon = wrapper.find(FontAwesomeIcon);
|
||||||
|
const getTooltipText = (): string => {
|
||||||
|
const children = tooltip.prop('children');
|
||||||
|
|
||||||
|
if (typeof children === 'string') {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return tooltip.find('span').html();
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(tooltip).toHaveLength(1);
|
||||||
|
expect(tooltip.prop('autohide')).toEqual(status === 'valid');
|
||||||
|
expect(getTooltipText()).toContain(expectedText);
|
||||||
|
expect(faIcon).toHaveLength(1);
|
||||||
|
expect(faIcon.prop('icon')).toEqual(expectedIcon);
|
||||||
|
expect(faIcon.prop('spin')).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ true, 'top-start' ],
|
||||||
|
[ false, 'left' ],
|
||||||
|
])('places the tooltip properly based on query match', (isMobile, expectedPlacement) => {
|
||||||
|
matchMedia.mockReturnValue(Mock.of<MediaQueryList>({ matches: isMobile }));
|
||||||
|
|
||||||
|
const wrapper = createWrapper('valid');
|
||||||
|
const tooltip = wrapper.find(UncontrolledTooltip);
|
||||||
|
|
||||||
|
expect(tooltip).toHaveLength(1);
|
||||||
|
expect(tooltip.prop('placement')).toEqual(expectedPlacement);
|
||||||
|
});
|
||||||
|
});
|
|
@ -4,19 +4,35 @@ import reducer, {
|
||||||
LIST_DOMAINS_ERROR,
|
LIST_DOMAINS_ERROR,
|
||||||
LIST_DOMAINS_START,
|
LIST_DOMAINS_START,
|
||||||
FILTER_DOMAINS,
|
FILTER_DOMAINS,
|
||||||
|
VALIDATE_DOMAIN,
|
||||||
DomainsCombinedAction,
|
DomainsCombinedAction,
|
||||||
DomainsList,
|
DomainsList,
|
||||||
listDomains as listDomainsAction,
|
listDomains as listDomainsAction,
|
||||||
filterDomains as filterDomainsAction,
|
filterDomains as filterDomainsAction,
|
||||||
replaceRedirectsOnDomain,
|
replaceRedirectsOnDomain,
|
||||||
|
checkDomainHealth,
|
||||||
|
replaceStatusOnDomain,
|
||||||
} from '../../../src/domains/reducers/domainsList';
|
} from '../../../src/domains/reducers/domainsList';
|
||||||
import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects';
|
import { EDIT_DOMAIN_REDIRECTS } from '../../../src/domains/reducers/domainRedirects';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../../src/api/types';
|
import { ShlinkDomainRedirects } from '../../../src/api/types';
|
||||||
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
|
||||||
|
import { Domain } from '../../../src/domains/data';
|
||||||
|
import { ShlinkState } from '../../../src/container/types';
|
||||||
|
import { SelectedServer, ServerData } from '../../../src/servers/data';
|
||||||
|
|
||||||
describe('domainsList', () => {
|
describe('domainsListReducer', () => {
|
||||||
const filteredDomains = [ Mock.of<ShlinkDomain>({ domain: 'foo' }), Mock.of<ShlinkDomain>({ domain: 'boo' }) ];
|
const dispatch = jest.fn();
|
||||||
const domains = [ ...filteredDomains, Mock.of<ShlinkDomain>({ domain: 'bar' }) ];
|
const getState = jest.fn();
|
||||||
|
const listDomains = jest.fn();
|
||||||
|
const health = jest.fn();
|
||||||
|
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains, health });
|
||||||
|
const filteredDomains = [
|
||||||
|
Mock.of<Domain>({ domain: 'foo', status: 'validating' }),
|
||||||
|
Mock.of<Domain>({ domain: 'boo', status: 'validating' }),
|
||||||
|
];
|
||||||
|
const domains = [ ...filteredDomains, Mock.of<Domain>({ domain: 'bar', status: 'validating' }) ];
|
||||||
|
|
||||||
|
beforeEach(jest.clearAllMocks);
|
||||||
|
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
|
const action = (type: string, args: Partial<DomainsCombinedAction> = {}) => Mock.of<DomainsCombinedAction>(
|
||||||
|
@ -66,16 +82,23 @@ describe('domainsList', () => {
|
||||||
filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
filteredDomains: filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'foo' ],
|
||||||
|
[ 'bar' ],
|
||||||
|
[ 'does_not_exist' ],
|
||||||
|
])('replaces status on proper domain on VALIDATE_DOMAIN', (domain) => {
|
||||||
|
expect(reducer(
|
||||||
|
Mock.of<DomainsList>({ domains, filteredDomains }),
|
||||||
|
action(VALIDATE_DOMAIN, { domain, status: 'valid' }),
|
||||||
|
)).toEqual({
|
||||||
|
domains: domains.map(replaceStatusOnDomain(domain, 'valid')),
|
||||||
|
filteredDomains: filteredDomains.map(replaceStatusOnDomain(domain, 'valid')),
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listDomains', () => {
|
describe('listDomains', () => {
|
||||||
const dispatch = jest.fn();
|
|
||||||
const getState = jest.fn();
|
|
||||||
const listDomains = jest.fn();
|
|
||||||
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ listDomains });
|
|
||||||
|
|
||||||
beforeEach(jest.clearAllMocks);
|
|
||||||
|
|
||||||
it('dispatches error when loading domains fails', async () => {
|
it('dispatches error when loading domains fails', async () => {
|
||||||
listDomains.mockRejectedValue(new Error('error'));
|
listDomains.mockRejectedValue(new Error('error'));
|
||||||
|
|
||||||
|
@ -88,13 +111,13 @@ describe('domainsList', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches domains once loaded', async () => {
|
it('dispatches domains once loaded', async () => {
|
||||||
listDomains.mockResolvedValue(domains);
|
listDomains.mockResolvedValue({ data: domains });
|
||||||
|
|
||||||
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
|
await listDomainsAction(buildShlinkApiClient)()(dispatch, getState);
|
||||||
|
|
||||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: LIST_DOMAINS_START });
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains });
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: LIST_DOMAINS, domains, defaultRedirects: undefined });
|
||||||
expect(listDomains).toHaveBeenCalledTimes(1);
|
expect(listDomains).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -108,4 +131,61 @@ describe('domainsList', () => {
|
||||||
expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm });
|
expect(filterDomainsAction(searchTerm)).toEqual({ type: FILTER_DOMAINS, searchTerm });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('checkDomainHealth', () => {
|
||||||
|
const domain = 'example.com';
|
||||||
|
|
||||||
|
it('dispatches invalid status when selected server does not have all required data', async () => {
|
||||||
|
getState.mockReturnValue(Mock.of<ShlinkState>({
|
||||||
|
selectedServer: Mock.all<SelectedServer>(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
|
||||||
|
|
||||||
|
expect(getState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(health).not.toHaveBeenCalled();
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches invalid status when health endpoint returns an error', async () => {
|
||||||
|
getState.mockReturnValue(Mock.of<ShlinkState>({
|
||||||
|
selectedServer: Mock.of<ServerData>({
|
||||||
|
url: 'https://myerver.com',
|
||||||
|
apiKey: '123',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
health.mockRejectedValue({});
|
||||||
|
|
||||||
|
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
|
||||||
|
|
||||||
|
expect(getState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(health).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'pass', 'valid' ],
|
||||||
|
[ 'fail', 'invalid' ],
|
||||||
|
])('dispatches proper status based on status returned from health endpoint', async (
|
||||||
|
healthStatus,
|
||||||
|
expectedStatus,
|
||||||
|
) => {
|
||||||
|
getState.mockReturnValue(Mock.of<ShlinkState>({
|
||||||
|
selectedServer: Mock.of<ServerData>({
|
||||||
|
url: 'https://myerver.com',
|
||||||
|
apiKey: '123',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
health.mockResolvedValue({ status: healthStatus });
|
||||||
|
|
||||||
|
await checkDomainHealth(buildShlinkApiClient)(domain)(dispatch, getState);
|
||||||
|
|
||||||
|
expect(getState).toHaveBeenCalledTimes(1);
|
||||||
|
expect(health).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith({ type: VALIDATE_DOMAIN, domain, status: expectedStatus });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -4,18 +4,21 @@ import { History } from 'history';
|
||||||
import createServerConstruct from '../../src/servers/CreateServer';
|
import createServerConstruct from '../../src/servers/CreateServer';
|
||||||
import { ServerForm } from '../../src/servers/helpers/ServerForm';
|
import { ServerForm } from '../../src/servers/helpers/ServerForm';
|
||||||
import { ServerWithId } from '../../src/servers/data';
|
import { ServerWithId } from '../../src/servers/data';
|
||||||
|
import { DuplicatedServersModal } from '../../src/servers/helpers/DuplicatedServersModal';
|
||||||
|
|
||||||
describe('<CreateServer />', () => {
|
describe('<CreateServer />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const ImportServersBtn = () => null;
|
const ImportServersBtn = () => null;
|
||||||
const createServerMock = jest.fn();
|
const createServerMock = jest.fn();
|
||||||
const push = jest.fn();
|
const push = jest.fn();
|
||||||
const historyMock = Mock.of<History>({ push });
|
const goBack = jest.fn();
|
||||||
|
const historyMock = Mock.of<History>({ push, goBack });
|
||||||
const servers = { foo: Mock.all<ServerWithId>() };
|
const servers = { foo: Mock.all<ServerWithId>() };
|
||||||
const createWrapper = (serversImported = false, importFailed = false) => {
|
const createWrapper = (serversImported = false, importFailed = false) => {
|
||||||
const useStateFlagTimeout = jest.fn()
|
const useStateFlagTimeout = jest.fn()
|
||||||
.mockReturnValueOnce([ serversImported, () => '' ])
|
.mockReturnValueOnce([ serversImported, () => '' ])
|
||||||
.mockReturnValueOnce([ importFailed, () => '' ]);
|
.mockReturnValueOnce([ importFailed, () => '' ])
|
||||||
|
.mockReturnValue([]);
|
||||||
const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout);
|
const CreateServer = createServerConstruct(ImportServersBtn, useStateFlagTimeout);
|
||||||
|
|
||||||
wrapper = shallow(<CreateServer createServer={createServerMock} history={historyMock} servers={servers} />);
|
wrapper = shallow(<CreateServer createServer={createServerMock} history={historyMock} servers={servers} />);
|
||||||
|
@ -23,10 +26,8 @@ describe('<CreateServer />', () => {
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
beforeEach(jest.clearAllMocks);
|
||||||
jest.resetAllMocks();
|
afterEach(() => wrapper?.unmount());
|
||||||
wrapper?.unmount();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders components', () => {
|
it('renders components', () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
|
@ -51,13 +52,30 @@ describe('<CreateServer />', () => {
|
||||||
expect(result.prop('type')).toEqual('error');
|
expect(result.prop('type')).toEqual('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates server and redirects to it when form is submitted', () => {
|
it('creates server data when form is submitted', () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const form = wrapper.find(ServerForm);
|
const form = wrapper.find(ServerForm);
|
||||||
|
|
||||||
|
expect(wrapper.find(DuplicatedServersModal).prop('duplicatedServers')).toEqual([]);
|
||||||
form.simulate('submit', {});
|
form.simulate('submit', {});
|
||||||
|
expect(wrapper.find(DuplicatedServersModal).prop('duplicatedServers')).toEqual([{}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves server and redirects on modal save', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
wrapper.find(ServerForm).simulate('submit', {});
|
||||||
|
wrapper.find(DuplicatedServersModal).simulate('save');
|
||||||
|
|
||||||
expect(createServerMock).toHaveBeenCalledTimes(1);
|
expect(createServerMock).toHaveBeenCalledTimes(1);
|
||||||
expect(push).toHaveBeenCalledTimes(1);
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('goes back on modal discard', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
wrapper.find(DuplicatedServersModal).simulate('discard');
|
||||||
|
|
||||||
|
expect(goBack).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -33,20 +33,20 @@ describe('<ManageServers />', () => {
|
||||||
bar: createServerMock('bar'),
|
bar: createServerMock('bar'),
|
||||||
baz: createServerMock('baz'),
|
baz: createServerMock('baz'),
|
||||||
});
|
});
|
||||||
const searchBar = wrapper.find(SearchField);
|
const searchField = wrapper.find(SearchField);
|
||||||
|
|
||||||
expect(wrapper.find(ManageServersRow)).toHaveLength(3);
|
expect(wrapper.find(ManageServersRow)).toHaveLength(3);
|
||||||
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
searchBar.simulate('change', 'foo');
|
searchField.simulate('change', 'foo');
|
||||||
expect(wrapper.find(ManageServersRow)).toHaveLength(1);
|
expect(wrapper.find(ManageServersRow)).toHaveLength(1);
|
||||||
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
searchBar.simulate('change', 'ba');
|
searchField.simulate('change', 'ba');
|
||||||
expect(wrapper.find(ManageServersRow)).toHaveLength(2);
|
expect(wrapper.find(ManageServersRow)).toHaveLength(2);
|
||||||
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(0);
|
||||||
|
|
||||||
searchBar.simulate('change', 'invalid');
|
searchField.simulate('change', 'invalid');
|
||||||
expect(wrapper.find(ManageServersRow)).toHaveLength(0);
|
expect(wrapper.find(ManageServersRow)).toHaveLength(0);
|
||||||
expect(wrapper.find('tbody').find('tr')).toHaveLength(1);
|
expect(wrapper.find('tbody').find('tr')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
106
test/servers/helpers/DuplicatedServersModal.test.tsx
Normal file
106
test/servers/helpers/DuplicatedServersModal.test.tsx
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { Button, ModalHeader } from 'reactstrap';
|
||||||
|
import { DuplicatedServersModal } from '../../../src/servers/helpers/DuplicatedServersModal';
|
||||||
|
import { ServerData } from '../../../src/servers/data';
|
||||||
|
|
||||||
|
describe('<DuplicatedServersModal />', () => {
|
||||||
|
const onDiscard = jest.fn();
|
||||||
|
const onSave = jest.fn();
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const createWrapper = (duplicatedServers: ServerData[] = []) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(jest.clearAllMocks);
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[[], 0 ],
|
||||||
|
[[ Mock.all<ServerData>() ], 2 ],
|
||||||
|
[[ Mock.all<ServerData>(), Mock.all<ServerData>() ], 2 ],
|
||||||
|
[[ Mock.all<ServerData>(), Mock.all<ServerData>(), Mock.all<ServerData>() ], 3 ],
|
||||||
|
[[ Mock.all<ServerData>(), Mock.all<ServerData>(), Mock.all<ServerData>(), Mock.all<ServerData>() ], 4 ],
|
||||||
|
])('renders expected amount of items', (duplicatedServers, expectedItems) => {
|
||||||
|
const wrapper = createWrapper(duplicatedServers);
|
||||||
|
const li = wrapper.find('li');
|
||||||
|
|
||||||
|
expect(li).toHaveLength(expectedItems);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
[ Mock.all<ServerData>() ],
|
||||||
|
{
|
||||||
|
header: 'Duplicated server',
|
||||||
|
firstParagraph: 'There is already a server with:',
|
||||||
|
lastParagraph: 'Do you want to save this server anyway?',
|
||||||
|
discardBtn: 'Discard',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[ Mock.all<ServerData>(), Mock.all<ServerData>() ],
|
||||||
|
{
|
||||||
|
header: 'Duplicated servers',
|
||||||
|
firstParagraph: 'The next servers already exist:',
|
||||||
|
lastParagraph: 'Do you want to ignore duplicated servers?',
|
||||||
|
discardBtn: 'Ignore duplicated',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
])('renders expected texts based on amount of servers', (duplicatedServers, assertions) => {
|
||||||
|
const wrapper = createWrapper(duplicatedServers);
|
||||||
|
const header = wrapper.find(ModalHeader);
|
||||||
|
const p = wrapper.find('p');
|
||||||
|
const span = wrapper.find('span');
|
||||||
|
const discardBtn = wrapper.find(Button).first();
|
||||||
|
|
||||||
|
expect(header.html()).toContain(assertions.header);
|
||||||
|
expect(p.html()).toContain(assertions.firstParagraph);
|
||||||
|
expect(span.html()).toContain(assertions.lastParagraph);
|
||||||
|
expect(discardBtn.html()).toContain(assertions.discardBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[[]],
|
||||||
|
[[ Mock.of<ServerData>({ url: 'url', apiKey: 'apiKey' }) ]],
|
||||||
|
])('displays provided server data', (duplicatedServers) => {
|
||||||
|
const wrapper = createWrapper(duplicatedServers);
|
||||||
|
const li = wrapper.find('li');
|
||||||
|
|
||||||
|
if (duplicatedServers.length === 0) {
|
||||||
|
expect(li).toHaveLength(0);
|
||||||
|
} else if (duplicatedServers.length === 1) {
|
||||||
|
expect(li.first().find('b').html()).toEqual(`<b>${duplicatedServers[0].url}</b>`);
|
||||||
|
expect(li.last().find('b').html()).toEqual(`<b>${duplicatedServers[0].apiKey}</b>`);
|
||||||
|
} else {
|
||||||
|
expect.assertions(duplicatedServers.length);
|
||||||
|
li.forEach((item, index) => {
|
||||||
|
const server = duplicatedServers[index];
|
||||||
|
|
||||||
|
expect(item.html()).toContain(`<b>${server.url}</b> - <b>${server.apiKey}</b>`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onDiscard when appropriate button is clicked', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const btn = wrapper.find(Button).first();
|
||||||
|
|
||||||
|
btn.simulate('click');
|
||||||
|
|
||||||
|
expect(onDiscard).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onSave when appropriate button is clicked', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const btn = wrapper.find(Button).last();
|
||||||
|
|
||||||
|
btn.simulate('click');
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,8 +2,9 @@ import { ReactNode } from 'react';
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import importServersBtnConstruct from '../../../src/servers/helpers/ImportServersBtn';
|
import importServersBtnConstruct, { ImportServersBtnProps } from '../../../src/servers/helpers/ImportServersBtn';
|
||||||
import ServersImporter from '../../../src/servers/services/ServersImporter';
|
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||||
|
import { DuplicatedServersModal } from '../../../src/servers/helpers/DuplicatedServersModal';
|
||||||
|
|
||||||
describe('<ImportServersBtn />', () => {
|
describe('<ImportServersBtn />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
|
@ -12,17 +13,15 @@ describe('<ImportServersBtn />', () => {
|
||||||
const importServersFromFile = jest.fn().mockResolvedValue([]);
|
const importServersFromFile = jest.fn().mockResolvedValue([]);
|
||||||
const serversImporterMock = Mock.of<ServersImporter>({ importServersFromFile });
|
const serversImporterMock = Mock.of<ServersImporter>({ importServersFromFile });
|
||||||
const click = jest.fn();
|
const click = jest.fn();
|
||||||
const fileRef = {
|
const fileRef = { current: Mock.of<HTMLInputElement>({ click }) };
|
||||||
current: Mock.of<HTMLInputElement>({ click }),
|
|
||||||
};
|
|
||||||
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
|
const ImportServersBtn = importServersBtnConstruct(serversImporterMock);
|
||||||
const createWrapper = (className?: string, children?: ReactNode) => {
|
const createWrapper = (props: Partial<ImportServersBtnProps & { children: ReactNode }> = {}) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<ImportServersBtn
|
<ImportServersBtn
|
||||||
createServers={createServersMock}
|
servers={{}}
|
||||||
className={className}
|
{...props}
|
||||||
fileRef={fileRef}
|
fileRef={fileRef}
|
||||||
children={children}
|
createServers={createServersMock}
|
||||||
onImport={onImportMock}
|
onImport={onImportMock}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
@ -46,7 +45,7 @@ describe('<ImportServersBtn />', () => {
|
||||||
[ 'foo', 'foo' ],
|
[ 'foo', 'foo' ],
|
||||||
[ 'bar', 'bar' ],
|
[ 'bar', 'bar' ],
|
||||||
])('allows a class name to be provided', (providedClassName, expectedClassName) => {
|
])('allows a class name to be provided', (providedClassName, expectedClassName) => {
|
||||||
const wrapper = createWrapper(providedClassName);
|
const wrapper = createWrapper({ className: providedClassName });
|
||||||
|
|
||||||
expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName);
|
expect(wrapper.find('#importBtn').prop('className')).toEqual(expectedClassName);
|
||||||
});
|
});
|
||||||
|
@ -56,7 +55,7 @@ describe('<ImportServersBtn />', () => {
|
||||||
[ 'foo', false ],
|
[ 'foo', false ],
|
||||||
[ 'bar', false ],
|
[ 'bar', false ],
|
||||||
])('has expected text', (children, expectToHaveDefaultText) => {
|
])('has expected text', (children, expectToHaveDefaultText) => {
|
||||||
const wrapper = createWrapper(undefined, children);
|
const wrapper = createWrapper({ children });
|
||||||
|
|
||||||
if (expectToHaveDefaultText) {
|
if (expectToHaveDefaultText) {
|
||||||
expect(wrapper.find('#importBtn').html()).toContain('Import from file');
|
expect(wrapper.find('#importBtn').html()).toContain('Import from file');
|
||||||
|
@ -82,6 +81,16 @@ describe('<ImportServersBtn />', () => {
|
||||||
await file.simulate('change', { target: { files: [ '' ] } }); // eslint-disable-line @typescript-eslint/await-thenable
|
await file.simulate('change', { target: { files: [ '' ] } }); // eslint-disable-line @typescript-eslint/await-thenable
|
||||||
|
|
||||||
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
expect(importServersFromFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'discard' ],
|
||||||
|
[ 'save' ],
|
||||||
|
])('invokes callback in DuplicatedServersModal events', (event) => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
|
||||||
|
wrapper.find(DuplicatedServersModal).simulate(event);
|
||||||
|
|
||||||
expect(createServersMock).toHaveBeenCalledTimes(1);
|
expect(createServersMock).toHaveBeenCalledTimes(1);
|
||||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
expect(onImportMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,7 +8,6 @@ import reducer, {
|
||||||
MAX_FALLBACK_VERSION,
|
MAX_FALLBACK_VERSION,
|
||||||
MIN_FALLBACK_VERSION,
|
MIN_FALLBACK_VERSION,
|
||||||
} from '../../../src/servers/reducers/selectedServer';
|
} from '../../../src/servers/reducers/selectedServer';
|
||||||
import { RESET_SHORT_URL_PARAMS } from '../../../src/short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShlinkState } from '../../../src/container/types';
|
import { ShlinkState } from '../../../src/container/types';
|
||||||
import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
|
import { NonReachableServer, NotFoundServer, RegularServer } from '../../../src/servers/data';
|
||||||
|
|
||||||
|
@ -62,10 +61,9 @@ describe('selectedServerReducer', () => {
|
||||||
|
|
||||||
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
||||||
|
|
||||||
expect(dispatch).toHaveBeenCalledTimes(4);
|
expect(dispatch).toHaveBeenCalledTimes(3);
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER });
|
expect(dispatch).toHaveBeenNthCalledWith(1, { type: RESET_SELECTED_SERVER });
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(2, { type: RESET_SHORT_URL_PARAMS });
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
|
||||||
expect(loadMercureInfo).toHaveBeenCalledTimes(1);
|
expect(loadMercureInfo).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -89,7 +87,7 @@ describe('selectedServerReducer', () => {
|
||||||
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
await selectServer(buildApiClient, loadMercureInfo)(id)(dispatch, getState);
|
||||||
|
|
||||||
expect(apiClientMock.health).toHaveBeenCalled();
|
expect(apiClientMock.health).toHaveBeenCalled();
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||||
expect(loadMercureInfo).not.toHaveBeenCalled();
|
expect(loadMercureInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -102,7 +100,7 @@ describe('selectedServerReducer', () => {
|
||||||
|
|
||||||
expect(getState).toHaveBeenCalled();
|
expect(getState).toHaveBeenCalled();
|
||||||
expect(apiClientMock.health).not.toHaveBeenCalled();
|
expect(apiClientMock.health).not.toHaveBeenCalled();
|
||||||
expect(dispatch).toHaveBeenNthCalledWith(3, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
expect(dispatch).toHaveBeenNthCalledWith(2, { type: SELECT_SERVER, selectedServer: expectedSelectedServer });
|
||||||
expect(loadMercureInfo).not.toHaveBeenCalled();
|
expect(loadMercureInfo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { CsvJson } from 'csvjson';
|
import { CsvJson } from 'csvjson';
|
||||||
import ServersImporter from '../../../src/servers/services/ServersImporter';
|
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||||
import { RegularServer } from '../../../src/servers/data';
|
import { RegularServer } from '../../../src/servers/data';
|
||||||
|
|
||||||
describe('ServersImporter', () => {
|
describe('ServersImporter', () => {
|
||||||
|
|
|
@ -1,19 +1,22 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { Input } from 'reactstrap';
|
import { Input } from 'reactstrap';
|
||||||
import { RealTimeUpdatesSettings, Settings } from '../../src/settings/reducers/settings';
|
import {
|
||||||
import RealTimeUpdates from '../../src/settings/RealTimeUpdates';
|
RealTimeUpdatesSettings as RealTimeUpdatesSettingsOptions,
|
||||||
|
Settings,
|
||||||
|
} from '../../src/settings/reducers/settings';
|
||||||
|
import RealTimeUpdatesSettings from '../../src/settings/RealTimeUpdatesSettings';
|
||||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||||
|
|
||||||
describe('<RealTimeUpdates />', () => {
|
describe('<RealTimeUpdatesSettings />', () => {
|
||||||
const toggleRealTimeUpdates = jest.fn();
|
const toggleRealTimeUpdates = jest.fn();
|
||||||
const setRealTimeUpdatesInterval = jest.fn();
|
const setRealTimeUpdatesInterval = jest.fn();
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (realTimeUpdates: Partial<RealTimeUpdatesSettings> = {}) => {
|
const createWrapper = (realTimeUpdates: Partial<RealTimeUpdatesSettingsOptions> = {}) => {
|
||||||
const settings = Mock.of<Settings>({ realTimeUpdates });
|
const settings = Mock.of<Settings>({ realTimeUpdates });
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<RealTimeUpdates
|
<RealTimeUpdatesSettings
|
||||||
settings={settings}
|
settings={settings}
|
||||||
toggleRealTimeUpdates={toggleRealTimeUpdates}
|
toggleRealTimeUpdates={toggleRealTimeUpdates}
|
||||||
setRealTimeUpdatesInterval={setRealTimeUpdatesInterval}
|
setRealTimeUpdatesInterval={setRealTimeUpdatesInterval}
|
|
@ -1,10 +1,10 @@
|
||||||
import { shallow } from 'enzyme';
|
import { shallow } from 'enzyme';
|
||||||
import createSettings from '../../src/settings/Settings';
|
import createSettings from '../../src/settings/Settings';
|
||||||
import NoMenuLayout from '../../src/common/NoMenuLayout';
|
import { NoMenuLayout } from '../../src/common/NoMenuLayout';
|
||||||
|
|
||||||
describe('<Settings />', () => {
|
describe('<Settings />', () => {
|
||||||
const Component = () => null;
|
const Component = () => null;
|
||||||
const Settings = createSettings(Component, Component, Component, Component);
|
const Settings = createSettings(Component, Component, Component, Component, Component, Component);
|
||||||
|
|
||||||
it('renders a no-menu layout with the expected settings sections', () => {
|
it('renders a no-menu layout with the expected settings sections', () => {
|
||||||
const wrapper = shallow(<Settings />);
|
const wrapper = shallow(<Settings />);
|
||||||
|
@ -13,6 +13,6 @@ describe('<Settings />', () => {
|
||||||
|
|
||||||
expect(layout).toHaveLength(1);
|
expect(layout).toHaveLength(1);
|
||||||
expect(sections).toHaveLength(1);
|
expect(sections).toHaveLength(1);
|
||||||
expect((sections.prop('items') as any[]).flat()).toHaveLength(4);
|
expect((sections.prop('items') as any[]).flat()).toHaveLength(6);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { ShortUrlCreationSettings, Settings } from '../../src/settings/reducers/settings';
|
import { ShortUrlCreationSettings as ShortUrlsSettings, Settings } from '../../src/settings/reducers/settings';
|
||||||
import { ShortUrlCreation } from '../../src/settings/ShortUrlCreation';
|
import { ShortUrlCreationSettings } from '../../src/settings/ShortUrlCreationSettings';
|
||||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||||
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
import { DropdownBtn } from '../../src/utils/DropdownBtn';
|
||||||
|
|
||||||
describe('<ShortUrlCreation />', () => {
|
describe('<ShortUrlCreationSettings />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const setShortUrlCreationSettings = jest.fn();
|
const setShortUrlCreationSettings = jest.fn();
|
||||||
const createWrapper = (shortUrlCreation?: ShortUrlCreationSettings) => {
|
const createWrapper = (shortUrlCreation?: ShortUrlsSettings) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<ShortUrlCreation
|
<ShortUrlCreationSettings
|
||||||
settings={Mock.of<Settings>({ shortUrlCreation })}
|
settings={Mock.of<Settings>({ shortUrlCreation })}
|
||||||
setShortUrlCreationSettings={setShortUrlCreationSettings}
|
setShortUrlCreationSettings={setShortUrlCreationSettings}
|
||||||
/>,
|
/>,
|
||||||
|
@ -68,9 +68,9 @@ describe('<ShortUrlCreation />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[ { tagFilteringMode: 'includes' } as ShortUrlCreationSettings, 'Suggest tags including input', 'including' ],
|
[ { tagFilteringMode: 'includes' } as ShortUrlsSettings, 'Suggest tags including input', 'including' ],
|
||||||
[
|
[
|
||||||
{ tagFilteringMode: 'startsWith' } as ShortUrlCreationSettings,
|
{ tagFilteringMode: 'startsWith' } as ShortUrlsSettings,
|
||||||
'Suggest tags starting with input',
|
'Suggest tags starting with input',
|
||||||
'starting with',
|
'starting with',
|
||||||
],
|
],
|
52
test/settings/ShortUrlsListSettings.test.tsx
Normal file
52
test/settings/ShortUrlsListSettings.test.tsx
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import {
|
||||||
|
DEFAULT_SHORT_URLS_ORDERING,
|
||||||
|
Settings,
|
||||||
|
ShortUrlsListSettings as ShortUrlsSettings,
|
||||||
|
} from '../../src/settings/reducers/settings';
|
||||||
|
import { ShortUrlsListSettings } from '../../src/settings/ShortUrlsListSettings';
|
||||||
|
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
||||||
|
import { ShortUrlsOrder } from '../../src/short-urls/data';
|
||||||
|
|
||||||
|
describe('<ShortUrlsListSettings />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const setSettings = jest.fn();
|
||||||
|
const createWrapper = (shortUrlsList?: ShortUrlsSettings) => {
|
||||||
|
wrapper = shallow(
|
||||||
|
<ShortUrlsListSettings settings={Mock.of<Settings>({ shortUrlsList })} setShortUrlsListSettings={setSettings} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, DEFAULT_SHORT_URLS_ORDERING ],
|
||||||
|
[{}, DEFAULT_SHORT_URLS_ORDERING ],
|
||||||
|
[{ defaultOrdering: {} }, {}],
|
||||||
|
[{ defaultOrdering: { field: 'longUrl', dir: 'DESC' } as ShortUrlsOrder }, { field: 'longUrl', dir: 'DESC' }],
|
||||||
|
[{ defaultOrdering: { field: 'visits', dir: 'ASC' } as ShortUrlsOrder }, { field: 'visits', dir: 'ASC' }],
|
||||||
|
])('shows expected ordering', (shortUrlsList, expectedOrder) => {
|
||||||
|
const wrapper = createWrapper(shortUrlsList);
|
||||||
|
const dropdown = wrapper.find(OrderingDropdown);
|
||||||
|
|
||||||
|
expect(dropdown.prop('order')).toEqual(expectedOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, undefined ],
|
||||||
|
[ 'longUrl', 'ASC' ],
|
||||||
|
[ 'visits', undefined ],
|
||||||
|
[ 'title', 'DESC' ],
|
||||||
|
])('invokes setSettings when ordering changes', (field, dir) => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const dropdown = wrapper.find(OrderingDropdown);
|
||||||
|
|
||||||
|
expect(setSettings).not.toHaveBeenCalled();
|
||||||
|
dropdown.simulate('change', field, dir);
|
||||||
|
expect(setSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } });
|
||||||
|
});
|
||||||
|
});
|
81
test/settings/TagsSettings.test.tsx
Normal file
81
test/settings/TagsSettings.test.tsx
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
import { Settings, TagsMode, TagsSettings as TagsSettingsOptions } from '../../src/settings/reducers/settings';
|
||||||
|
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
||||||
|
import { TagsSettings } from '../../src/settings/TagsSettings';
|
||||||
|
import { OrderingDropdown } from '../../src/utils/OrderingDropdown';
|
||||||
|
import { TagsOrder } from '../../src/tags/data/TagsListChildrenProps';
|
||||||
|
|
||||||
|
describe('<TagsSettings />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const setTagsSettings = jest.fn();
|
||||||
|
const createWrapper = (tags?: TagsSettingsOptions) => {
|
||||||
|
wrapper = shallow(<TagsSettings settings={Mock.of<Settings>({ tags })} setTagsSettings={setTagsSettings} />);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => wrapper?.unmount());
|
||||||
|
afterEach(jest.clearAllMocks);
|
||||||
|
|
||||||
|
it('renders expected amount of groups', () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const groups = wrapper.find(FormGroup);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, 'cards' ],
|
||||||
|
[{}, 'cards' ],
|
||||||
|
[{ defaultMode: 'cards' as TagsMode }, 'cards' ],
|
||||||
|
[{ defaultMode: 'list' as TagsMode }, 'list' ],
|
||||||
|
])('shows expected tags displaying mode', (tags, expectedMode) => {
|
||||||
|
const wrapper = createWrapper(tags);
|
||||||
|
const dropdown = wrapper.find(TagsModeDropdown);
|
||||||
|
const small = wrapper.find('small');
|
||||||
|
|
||||||
|
expect(dropdown.prop('mode')).toEqual(expectedMode);
|
||||||
|
expect(small.html()).toContain(`Tags will be displayed as <b>${expectedMode}</b>.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ 'cards' as TagsMode ],
|
||||||
|
[ 'list' as TagsMode ],
|
||||||
|
])('invokes setTagsSettings when tags mode changes', (defaultMode) => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const dropdown = wrapper.find(TagsModeDropdown);
|
||||||
|
|
||||||
|
expect(setTagsSettings).not.toHaveBeenCalled();
|
||||||
|
dropdown.simulate('change', defaultMode);
|
||||||
|
expect(setTagsSettings).toHaveBeenCalledWith({ defaultMode });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, {}],
|
||||||
|
[{}, {}],
|
||||||
|
[{ defaultOrdering: {} }, {}],
|
||||||
|
[{ defaultOrdering: { field: 'tag', dir: 'DESC' } as TagsOrder }, { field: 'tag', dir: 'DESC' }],
|
||||||
|
[{ defaultOrdering: { field: 'visits', dir: 'ASC' } as TagsOrder }, { field: 'visits', dir: 'ASC' }],
|
||||||
|
])('shows expected ordering', (tags, expectedOrder) => {
|
||||||
|
const wrapper = createWrapper(tags);
|
||||||
|
const dropdown = wrapper.find(OrderingDropdown);
|
||||||
|
|
||||||
|
expect(dropdown.prop('order')).toEqual(expectedOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[ undefined, undefined ],
|
||||||
|
[ 'tag', 'ASC' ],
|
||||||
|
[ 'visits', undefined ],
|
||||||
|
[ 'shortUrls', 'DESC' ],
|
||||||
|
])('invokes setTagsSettings when ordering changes', (field, dir) => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const dropdown = wrapper.find(OrderingDropdown);
|
||||||
|
|
||||||
|
expect(setTagsSettings).not.toHaveBeenCalled();
|
||||||
|
dropdown.simulate('change', field, dir);
|
||||||
|
expect(setTagsSettings).toHaveBeenCalledWith({ defaultOrdering: { field, dir } });
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,17 +2,16 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
import { faMoon, faSun } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Settings, TagsMode, UiSettings } from '../../src/settings/reducers/settings';
|
import { Settings, UiSettings } from '../../src/settings/reducers/settings';
|
||||||
import { UserInterface } from '../../src/settings/UserInterface';
|
import { UserInterfaceSettings } from '../../src/settings/UserInterfaceSettings';
|
||||||
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
import ToggleSwitch from '../../src/utils/ToggleSwitch';
|
||||||
import { Theme } from '../../src/utils/theme';
|
import { Theme } from '../../src/utils/theme';
|
||||||
import { TagsModeDropdown } from '../../src/tags/TagsModeDropdown';
|
|
||||||
|
|
||||||
describe('<UserInterface />', () => {
|
describe('<UserInterfaceSettings />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const setUiSettings = jest.fn();
|
const setUiSettings = jest.fn();
|
||||||
const createWrapper = (ui?: UiSettings) => {
|
const createWrapper = (ui?: UiSettings) => {
|
||||||
wrapper = shallow(<UserInterface settings={Mock.of<Settings>({ ui })} setUiSettings={setUiSettings} />);
|
wrapper = shallow(<UserInterfaceSettings settings={Mock.of<Settings>({ ui })} setUiSettings={setUiSettings} />);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
@ -53,30 +52,4 @@ describe('<UserInterface />', () => {
|
||||||
toggle.simulate('change', checked);
|
toggle.simulate('change', checked);
|
||||||
expect(setUiSettings).toHaveBeenCalledWith({ theme });
|
expect(setUiSettings).toHaveBeenCalledWith({ theme });
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
[ undefined, 'cards' ],
|
|
||||||
[{ theme: 'light' as Theme }, 'cards' ],
|
|
||||||
[{ theme: 'light' as Theme, tagsMode: 'cards' as TagsMode }, 'cards' ],
|
|
||||||
[{ theme: 'light' as Theme, tagsMode: 'list' as TagsMode }, 'list' ],
|
|
||||||
])('shows expected tags displaying mode', (ui, expectedMode) => {
|
|
||||||
const wrapper = createWrapper(ui);
|
|
||||||
const dropdown = wrapper.find(TagsModeDropdown);
|
|
||||||
const small = wrapper.find('small');
|
|
||||||
|
|
||||||
expect(dropdown.prop('mode')).toEqual(expectedMode);
|
|
||||||
expect(small.html()).toContain(`Tags will be displayed as <b>${expectedMode}</b>.`);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[ 'cards' as TagsMode ],
|
|
||||||
[ 'list' as TagsMode ],
|
|
||||||
])('invokes setUiSettings when tags mode changes', (tagsMode) => {
|
|
||||||
const wrapper = createWrapper();
|
|
||||||
const dropdown = wrapper.find(TagsModeDropdown);
|
|
||||||
|
|
||||||
expect(setUiSettings).not.toHaveBeenCalled();
|
|
||||||
dropdown.simulate('change', tagsMode);
|
|
||||||
expect(setUiSettings).toHaveBeenCalledWith({ theme: 'light', tagsMode });
|
|
||||||
});
|
|
||||||
});
|
});
|
|
@ -1,15 +1,15 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { Settings } from '../../src/settings/reducers/settings';
|
import { Settings } from '../../src/settings/reducers/settings';
|
||||||
import { Visits } from '../../src/settings/Visits';
|
import { VisitsSettings } from '../../src/settings/VisitsSettings';
|
||||||
import { SimpleCard } from '../../src/utils/SimpleCard';
|
import { SimpleCard } from '../../src/utils/SimpleCard';
|
||||||
import { DateIntervalSelector } from '../../src/utils/dates/DateIntervalSelector';
|
import { DateIntervalSelector } from '../../src/utils/dates/DateIntervalSelector';
|
||||||
|
|
||||||
describe('<Visits />', () => {
|
describe('<VisitsSettings />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const setVisitsSettings = jest.fn();
|
const setVisitsSettings = jest.fn();
|
||||||
const createWrapper = (settings: Partial<Settings> = {}) => {
|
const createWrapper = (settings: Partial<Settings> = {}) => {
|
||||||
wrapper = shallow(<Visits settings={Mock.of<Settings>(settings)} setVisitsSettings={setVisitsSettings} />);
|
wrapper = shallow(<VisitsSettings settings={Mock.of<Settings>(settings)} setVisitsSettings={setVisitsSettings} />);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
};
|
};
|
||||||
|
@ -55,12 +55,12 @@ describe('<Visits />', () => {
|
||||||
const selector = wrapper.find(DateIntervalSelector);
|
const selector = wrapper.find(DateIntervalSelector);
|
||||||
|
|
||||||
selector.simulate('change', 'last7Days');
|
selector.simulate('change', 'last7Days');
|
||||||
selector.simulate('change', 'last180days');
|
selector.simulate('change', 'last180Days');
|
||||||
selector.simulate('change', 'yesterday');
|
selector.simulate('change', 'yesterday');
|
||||||
|
|
||||||
expect(setVisitsSettings).toHaveBeenCalledTimes(3);
|
expect(setVisitsSettings).toHaveBeenCalledTimes(3);
|
||||||
expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' });
|
expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' });
|
||||||
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180days' });
|
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
|
||||||
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
|
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
|
||||||
});
|
});
|
||||||
});
|
});
|
35
test/settings/helpers/index.test.ts
Normal file
35
test/settings/helpers/index.test.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { migrateDeprecatedSettings } from '../../../src/settings/helpers';
|
||||||
|
import { ShlinkState } from '../../../src/container/types';
|
||||||
|
|
||||||
|
describe('settings-helpers', () => {
|
||||||
|
describe('migrateDeprecatedSettings', () => {
|
||||||
|
it('returns object as is if settings are not set', () => {
|
||||||
|
expect(migrateDeprecatedSettings({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates settings as expected', () => {
|
||||||
|
const state = Mock.of<ShlinkState>({
|
||||||
|
settings: {
|
||||||
|
visits: {
|
||||||
|
defaultInterval: 'last180days' as any,
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
tagsMode: 'list',
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(migrateDeprecatedSettings(state)).toEqual(expect.objectContaining({
|
||||||
|
settings: expect.objectContaining({
|
||||||
|
visits: {
|
||||||
|
defaultInterval: 'last180Days',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
defaultMode: 'list',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,10 +1,13 @@
|
||||||
import reducer, {
|
import reducer, {
|
||||||
SET_SETTINGS,
|
SET_SETTINGS,
|
||||||
|
DEFAULT_SHORT_URLS_ORDERING,
|
||||||
toggleRealTimeUpdates,
|
toggleRealTimeUpdates,
|
||||||
setRealTimeUpdatesInterval,
|
setRealTimeUpdatesInterval,
|
||||||
setShortUrlCreationSettings,
|
setShortUrlCreationSettings,
|
||||||
setUiSettings,
|
setUiSettings,
|
||||||
setVisitsSettings,
|
setVisitsSettings,
|
||||||
|
setTagsSettings,
|
||||||
|
setShortUrlsListSettings,
|
||||||
} from '../../../src/settings/reducers/settings';
|
} from '../../../src/settings/reducers/settings';
|
||||||
|
|
||||||
describe('settingsReducer', () => {
|
describe('settingsReducer', () => {
|
||||||
|
@ -12,7 +15,8 @@ describe('settingsReducer', () => {
|
||||||
const shortUrlCreation = { validateUrls: false };
|
const shortUrlCreation = { validateUrls: false };
|
||||||
const ui = { theme: 'light' };
|
const ui = { theme: 'light' };
|
||||||
const visits = { defaultInterval: 'last30Days' };
|
const visits = { defaultInterval: 'last30Days' };
|
||||||
const settings = { realTimeUpdates, shortUrlCreation, ui, visits };
|
const shortUrlsList = { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING };
|
||||||
|
const settings = { realTimeUpdates, shortUrlCreation, ui, visits, shortUrlsList };
|
||||||
|
|
||||||
describe('reducer', () => {
|
describe('reducer', () => {
|
||||||
it('returns realTimeUpdates when action is SET_SETTINGS', () => {
|
it('returns realTimeUpdates when action is SET_SETTINGS', () => {
|
||||||
|
@ -54,9 +58,25 @@ describe('settingsReducer', () => {
|
||||||
|
|
||||||
describe('setVisitsSettings', () => {
|
describe('setVisitsSettings', () => {
|
||||||
it('creates action to set visits settings', () => {
|
it('creates action to set visits settings', () => {
|
||||||
const result = setVisitsSettings({ defaultInterval: 'last180days' });
|
const result = setVisitsSettings({ defaultInterval: 'last180Days' });
|
||||||
|
|
||||||
expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } });
|
expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setTagsSettings', () => {
|
||||||
|
it('creates action to set tags settings', () => {
|
||||||
|
const result = setTagsSettings({ defaultMode: 'list' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: SET_SETTINGS, tags: { defaultMode: 'list' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setShortUrlsListSettings', () => {
|
||||||
|
it('creates action to set short URLs list settings', () => {
|
||||||
|
const result = setShortUrlsListSettings({ defaultOrdering: DEFAULT_SHORT_URLS_ORDERING });
|
||||||
|
|
||||||
|
expect(result).toEqual({ type: SET_SETTINGS, shortUrlsList: { defaultOrdering: DEFAULT_SHORT_URLS_ORDERING } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,21 +3,21 @@ import { Mock } from 'ts-mockery';
|
||||||
import { History, Location } from 'history';
|
import { History, Location } from 'history';
|
||||||
import { match } from 'react-router';
|
import { match } from 'react-router';
|
||||||
import { formatISO } from 'date-fns';
|
import { formatISO } from 'date-fns';
|
||||||
import searchBarCreator, { SearchBarProps } from '../../src/short-urls/SearchBar';
|
import filteringBarCreator, { ShortUrlsFilteringProps } from '../../src/short-urls/ShortUrlsFilteringBar';
|
||||||
import SearchField from '../../src/utils/SearchField';
|
import SearchField from '../../src/utils/SearchField';
|
||||||
import Tag from '../../src/tags/helpers/Tag';
|
import Tag from '../../src/tags/helpers/Tag';
|
||||||
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../../src/utils/dates/DateRangeSelector';
|
||||||
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
import ColorGenerator from '../../src/utils/services/ColorGenerator';
|
||||||
import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks';
|
import { ShortUrlListRouteParams } from '../../src/short-urls/helpers/hooks';
|
||||||
|
|
||||||
describe('<SearchBar />', () => {
|
describe('<ShortUrlsFilteringBar />', () => {
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const SearchBar = searchBarCreator(Mock.all<ColorGenerator>());
|
const ShortUrlsFilteringBar = filteringBarCreator(Mock.all<ColorGenerator>());
|
||||||
const push = jest.fn();
|
const push = jest.fn();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const createWrapper = (props: Partial<SearchBarProps> = {}) => {
|
const createWrapper = (props: Partial<ShortUrlsFilteringProps> = {}) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<SearchBar
|
<ShortUrlsFilteringBar
|
||||||
history={Mock.of<History>({ push })}
|
history={Mock.of<History>({ push })}
|
||||||
location={Mock.of<Location>({ search: '' })}
|
location={Mock.of<Location>({ search: '' })}
|
||||||
match={Mock.of<match<ShortUrlListRouteParams>>({ params: { serverId: '1' } })}
|
match={Mock.of<match<ShortUrlListRouteParams>>({ params: { serverId: '1' } })}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue