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