Merge pull request #553 from shlinkio/develop

Release 3.5.0
This commit is contained in:
Alejandro Celaya 2022-01-01 12:50:47 +01:00 committed by GitHub
commit 552169ee77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
116 changed files with 1791 additions and 600 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View file

@ -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);
}
};
} }

View file

@ -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 {

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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 />

View file

@ -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>

View file

@ -0,0 +1,7 @@
import { ShlinkDomain } from '../../api/types';
export type DomainStatus = 'validating' | 'valid' | 'invalid';
export interface Domain extends ShlinkDomain {
status: DomainStatus;
}

View 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>
</>
);
};

View file

@ -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' });
}
};

View file

@ -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;

View file

@ -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';

View file

@ -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,

View file

@ -1,10 +0,0 @@
@import '../utils/base';
.create-server__label {
font-weight: 700;
cursor: pointer;
@media (min-width: $mdMin) {
text-align: right;
}
}

View file

@ -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>
); );
}; };

View file

@ -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';

View file

@ -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';

View file

@ -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();
}, []); }, []);

View 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>
);
};

View file

@ -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}
/>
</> </>
); );
}; };

View file

@ -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 {

View file

@ -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;
}

View file

@ -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('');

View file

@ -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;

View file

@ -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];

View file

@ -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[]> => {

View file

@ -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' ]));

View file

@ -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;

View file

@ -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>

View file

@ -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 },
); );

View 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>
);

View 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>
);

View file

@ -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>
); );

View file

@ -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>

View 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;
};

View file

@ -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,
});

View file

@ -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;

View file

@ -1,3 +0,0 @@
.search-bar__tags-icon {
vertical-align: bottom;
}

View file

@ -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}

View file

@ -0,0 +1,3 @@
.short-urls-filtering-bar__tags-icon {
vertical-align: bottom;
}

View file

@ -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" />
&nbsp; &nbsp;
{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;

View file

@ -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

View file

@ -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;

View file

@ -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>;

View file

@ -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}`);

View file

@ -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 });
} }
}; };

View file

@ -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);

View file

@ -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);

View file

@ -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()}

View file

@ -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;
} }

View file

@ -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[];

View 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;
}

View file

@ -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>

View file

@ -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;
}

View file

@ -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} />

View file

@ -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' ],
])();
};

View file

@ -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);

View file

@ -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' });

View file

@ -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);
};

View file

@ -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
View 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
View file

@ -0,0 +1 @@
export type MediaMatcher = (query: string) => MediaQueryList;

View file

@ -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

View file

@ -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,

View file

@ -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 (

View file

@ -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"

View file

@ -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;
} }

View file

@ -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}

View file

@ -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]);
};

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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 {

View file

@ -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, '', '');

View file

@ -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 />', () => {

View file

@ -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');

View file

@ -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', () => {

View 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);
});
});

View file

@ -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 });
});
});
}); });

View file

@ -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);
});
}); });

View file

@ -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);
}); });

View 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();
});
});

View file

@ -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);
}); });

View file

@ -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();
}); });
}); });

View file

@ -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', () => {

View file

@ -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}

View file

@ -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);
}); });
}); });

View file

@ -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',
], ],

View 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 } });
});
});

View 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 } });
});
});

View file

@ -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 });
});
}); });

View file

@ -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' });
}); });
}); });

View 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',
},
}),
}));
});
});
});

View file

@ -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 } });
}); });
}); });
}); });

View file

@ -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