mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
commit
7d83e434e6
173 changed files with 9562 additions and 8322 deletions
25
CHANGELOG.md
25
CHANGELOG.md
|
@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [3.8.0] - 2022-12-03
|
||||||
|
### Added
|
||||||
|
* [#708](https://github.com/shlinkio/shlink-web-client/issues/708) Added support for API v3.
|
||||||
|
* [#717](https://github.com/shlinkio/shlink-web-client/issues/717) Allowed to select time in 10 minute intervals when configuring "enabled since" and "enabled until" on short URLs.
|
||||||
|
* [#748](https://github.com/shlinkio/shlink-web-client/issues/748) Improved visits section to add filters to the query string, allowing to navigate to a specific state or bookmarking filters.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies.
|
||||||
|
* [#620](https://github.com/shlinkio/shlink-web-client/issues/620) Migrated all reducers to redux toolkit.
|
||||||
|
* [#721](https://github.com/shlinkio/shlink-web-client/issues/721) Migrated from axios to fetch.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#590](https://github.com/shlinkio/shlink-web-client/issues/590) Fixed position of the datepicker triangle.
|
||||||
|
* [#729](https://github.com/shlinkio/shlink-web-client/issues/729) Fixed wrong stats displayed in tags after renaming.
|
||||||
|
* [#737](https://github.com/shlinkio/shlink-web-client/issues/737) Fixed incorrect contrast in warning messages when using dark theme.
|
||||||
|
* [#726](https://github.com/shlinkio/shlink-web-client/issues/726) Fixed delete server and delete short URL modals getting removed from the DOM before finishing close transition.
|
||||||
|
* [#749](https://github.com/shlinkio/shlink-web-client/issues/749) Fixed broken short URLs table when some short URL has a too long custom slug.
|
||||||
|
|
||||||
|
|
||||||
## [3.7.3] - 2022-09-13
|
## [3.7.3] - 2022-09-13
|
||||||
### Added
|
### Added
|
||||||
* [#703](https://github.com/shlinkio/shlink-web-client/issues/703) Added support to publish docker image in GHCR.
|
* [#703](https://github.com/shlinkio/shlink-web-client/issues/703) Added support to publish docker image in GHCR.
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink-web-client.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink-web-client/)
|
||||||
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
[![GitHub license](https://img.shields.io/github/license/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/blob/main/LICENSE)
|
||||||
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
|
[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio)
|
||||||
|
[![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio)
|
||||||
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
[![Paypal Donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=cccccc)](https://slnk.to/donate)
|
||||||
|
|
||||||
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
A ReactJS-based progressive web application for [Shlink](https://shlink.io).
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
import 'jest-canvas-mock';
|
import 'jest-canvas-mock';
|
||||||
import ResizeObserver from 'resize-observer-polyfill';
|
import ResizeObserver from 'resize-observer-polyfill';
|
||||||
|
import { setAutoFreeze } from 'immer';
|
||||||
|
|
||||||
(global as any).ResizeObserver = ResizeObserver;
|
(global as any).ResizeObserver = ResizeObserver;
|
||||||
(global as any).scrollTo = () => {};
|
(global as any).scrollTo = () => {};
|
||||||
(global as any).prompt = () => {};
|
(global as any).prompt = () => {};
|
||||||
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
(global as any).matchMedia = (media: string) => ({ matches: false, media });
|
||||||
|
|
||||||
|
setAutoFreeze(false); // TODO Bypassing a bug on jest
|
||||||
|
|
|
@ -17,6 +17,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||||
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/.stryker-tmp'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testEnvironmentOptions: {
|
testEnvironmentOptions: {
|
||||||
url: 'http://localhost',
|
url: 'http://localhost',
|
||||||
|
|
11136
package-lock.json
generated
11136
package-lock.json
generated
File diff suppressed because it is too large
Load diff
103
package.json
103
package.json
|
@ -24,87 +24,88 @@
|
||||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
"mutate": "./node_modules/.bin/stryker run --concurrency 4 --ignoreStatic"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.0.0",
|
"@fortawesome/fontawesome-free": "^6.2.0",
|
||||||
"@fortawesome/fontawesome-svg-core": "^1.3.0",
|
"@fortawesome/fontawesome-svg-core": "^6.2.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.0.0",
|
"@fortawesome/free-regular-svg-icons": "^6.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.0.0",
|
"@fortawesome/free-solid-svg-icons": "^6.2.0",
|
||||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||||
"axios": "^0.26.0",
|
"@reduxjs/toolkit": "^1.9.0",
|
||||||
"bootstrap": "^5.1.3",
|
"bootstrap": "^5.2.2",
|
||||||
"bottlejs": "^2.0.0",
|
"bottlejs": "^2.0.1",
|
||||||
"bowser": "^2.11.0",
|
"bowser": "^2.11.0",
|
||||||
"chart.js": "^3.7.1",
|
"chart.js": "^3.9.1",
|
||||||
"classnames": "^2.3.1",
|
"classnames": "^2.3.1",
|
||||||
"compare-versions": "^4.1.3",
|
"compare-versions": "^5.0.1",
|
||||||
"csvtojson": "^2.0.10",
|
"csvtojson": "^2.0.10",
|
||||||
"date-fns": "^2.28.0",
|
"date-fns": "^2.29.3",
|
||||||
"event-source-polyfill": "^1.0.25",
|
"event-source-polyfill": "^1.0.31",
|
||||||
|
"history": "^5.3.0",
|
||||||
"json2csv": "^5.0.7",
|
"json2csv": "^5.0.7",
|
||||||
"leaflet": "^1.7.1",
|
"leaflet": "^1.9.2",
|
||||||
"qs": "^6.9.6",
|
"qs": "^6.11.0",
|
||||||
"ramda": "^0.27.2",
|
"ramda": "^0.27.2",
|
||||||
"react": "^18.1.0",
|
"react": "^18.2.0",
|
||||||
"react-chartjs-2": "^4.1.0",
|
"react-chartjs-2": "^4.3.1",
|
||||||
"react-colorful": "^5.5.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-copy-to-clipboard": "^5.1.0",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-datepicker": "^4.8.0",
|
"react-datepicker": "^4.8.0",
|
||||||
"react-dom": "^18.1.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-external-link": "^2.0.0",
|
"react-external-link": "^2.0.0",
|
||||||
"react-leaflet": "^4.0.0",
|
"react-leaflet": "^4.1.0",
|
||||||
"react-redux": "^8.0.0",
|
"react-redux": "^8.0.4",
|
||||||
"react-router-dom": "^6.3.0",
|
"react-router-dom": "^6.4.1",
|
||||||
"react-swipeable": "^7.0.0",
|
"react-swipeable": "^7.0.0",
|
||||||
"react-tag-autocomplete": "^6.3.0",
|
"react-tag-autocomplete": "^6.3.0",
|
||||||
"reactstrap": "^9.0.1",
|
"reactstrap": "^9.1.4",
|
||||||
"redux": "^4.2.0",
|
"redux": "^4.2.0",
|
||||||
"redux-localstorage-simple": "^2.4.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"redux-thunk": "^2.4.1",
|
"redux-thunk": "^2.4.1",
|
||||||
"stream": "^0.0.2",
|
"stream": "^0.0.2",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"workbox-core": "^6.5.1",
|
"workbox-core": "^6.5.4",
|
||||||
"workbox-expiration": "^6.5.1",
|
"workbox-expiration": "^6.5.4",
|
||||||
"workbox-precaching": "^6.5.1",
|
"workbox-precaching": "^6.5.4",
|
||||||
"workbox-routing": "^6.5.1",
|
"workbox-routing": "^6.5.4",
|
||||||
"workbox-strategies": "^6.5.1"
|
"workbox-strategies": "^6.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~2.0.2",
|
||||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
"@shlinkio/stylelint-config-css-coding-standard": "~1.0.1",
|
||||||
"@stryker-mutator/core": "^6.0.2",
|
"@stryker-mutator/core": "^6.2.2",
|
||||||
"@stryker-mutator/jest-runner": "^6.0.2",
|
"@stryker-mutator/jest-runner": "^6.2.2",
|
||||||
"@stryker-mutator/typescript-checker": "^6.0.2",
|
"@stryker-mutator/typescript-checker": "^6.2.2",
|
||||||
"@testing-library/jest-dom": "^5.16.4",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/react": "^13.1.1",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@testing-library/user-event": "^14.1.1",
|
"@testing-library/user-event": "^14.4.3",
|
||||||
"@types/jest": "^27.4.1",
|
"@types/jest": "^29.1.1",
|
||||||
"@types/json2csv": "^5.0.3",
|
"@types/json2csv": "^5.0.3",
|
||||||
"@types/leaflet": "^1.7.9",
|
"@types/leaflet": "^1.8.0",
|
||||||
"@types/qs": "^6.9.7",
|
"@types/qs": "^6.9.7",
|
||||||
"@types/ramda": "0.27.38",
|
"@types/ramda": "^0.28.15",
|
||||||
"@types/react": "^18.0.8",
|
"@types/react": "^18.0.21",
|
||||||
"@types/react-color": "^3.0.6",
|
"@types/react-color": "^3.0.6",
|
||||||
"@types/react-copy-to-clipboard": "^5.0.2",
|
"@types/react-copy-to-clipboard": "^5.0.4",
|
||||||
"@types/react-datepicker": "^4.3.4",
|
"@types/react-datepicker": "^4.4.2",
|
||||||
"@types/react-dom": "^18.0.3",
|
"@types/react-dom": "^18.0.6",
|
||||||
"@types/react-tag-autocomplete": "^6.1.1",
|
"@types/react-tag-autocomplete": "^6.3.0",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"adm-zip": "^0.5.9",
|
"adm-zip": "^0.5.9",
|
||||||
"babel-jest": "^28.0.3",
|
"babel-jest": "^29.1.2",
|
||||||
"chalk": "^5.0.1",
|
"chalk": "^5.0.1",
|
||||||
"eslint": "^8.12.0",
|
"eslint": "^8.24.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^28.0.3",
|
"jest": "^29.1.2",
|
||||||
"jest-canvas-mock": "^2.4.0",
|
"jest-canvas-mock": "^2.4.0",
|
||||||
"jest-environment-jsdom": "^28.0.2",
|
"jest-environment-jsdom": "^29.1.2",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"sass": "^1.49.9",
|
"sass": "^1.55.0",
|
||||||
"serve": "^13.0.2",
|
"serve": "^14.1.1",
|
||||||
"stryker-cli": "^1.0.2",
|
"stryker-cli": "^1.0.2",
|
||||||
"stylelint": "^14.8.2",
|
"stylelint": "^14.13.0",
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^4.6.2",
|
"typescript": "^4.8.4",
|
||||||
"webpack": "^5.70.0"
|
"webpack": "^5.74.0"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ProblemDetailsError } from './types';
|
|
||||||
import { isInvalidArgumentError } from './utils';
|
import { isInvalidArgumentError } from './utils';
|
||||||
|
import { ProblemDetailsError } from './types/errors';
|
||||||
|
|
||||||
export interface ShlinkApiErrorProps {
|
export interface ShlinkApiErrorProps {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
|
||||||
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlData } from '../../short-urls/data';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import {
|
import {
|
||||||
|
@ -18,111 +17,122 @@ import {
|
||||||
ShlinkShortUrlsListParams,
|
ShlinkShortUrlsListParams,
|
||||||
ShlinkShortUrlsListNormalizedParams,
|
ShlinkShortUrlsListNormalizedParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
|
||||||
import { orderToString } from '../../utils/helpers/ordering';
|
import { orderToString } from '../../utils/helpers/ordering';
|
||||||
|
import { isRegularNotFound, parseApiError } from '../utils';
|
||||||
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { HttpClient } from '../../common/services/HttpClient';
|
||||||
|
|
||||||
const buildShlinkBaseUrl = (url: string) => (url ? `${url}/rest/v2` : '');
|
const buildShlinkBaseUrl = (url: string, version: 2 | 3) => `${url}/rest/v${version}`;
|
||||||
const rejectNilProps = reject(isNil);
|
const rejectNilProps = reject(isNil);
|
||||||
const normalizeOrderByInParams = (params: ShlinkShortUrlsListParams): ShlinkShortUrlsListNormalizedParams => {
|
const normalizeOrderByInParams = (
|
||||||
const { orderBy = {}, ...rest } = params;
|
{ orderBy = {}, ...rest }: ShlinkShortUrlsListParams,
|
||||||
|
): ShlinkShortUrlsListNormalizedParams => ({ ...rest, orderBy: orderToString(orderBy) });
|
||||||
return { ...rest, orderBy: orderToString(orderBy) };
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ShlinkApiClient {
|
export class ShlinkApiClient {
|
||||||
|
private apiVersion: 2 | 3;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly axios: AxiosInstance,
|
private readonly httpClient: HttpClient,
|
||||||
private readonly baseUrl: string,
|
private readonly baseUrl: string,
|
||||||
private readonly apiKey: string,
|
private readonly apiKey: string,
|
||||||
) {
|
) {
|
||||||
|
this.apiVersion = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', normalizeOrderByInParams(params))
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ shortUrls }) => shortUrls);
|
||||||
|
|
||||||
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
public readonly createShortUrl = async (options: ShortUrlData): Promise<ShortUrl> => {
|
||||||
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
|
const filteredOptions = reject((value) => isEmpty(value) || isNil(value), options as any);
|
||||||
|
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions);
|
||||||
return this.performRequest<ShortUrl>('/short-urls', 'POST', {}, filteredOptions)
|
|
||||||
.then((resp) => resp.data);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
|
public readonly getShortUrlVisits = async (shortCode: string, query?: ShlinkVisitsParams): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/short-urls/${shortCode}/visits`, 'GET', query)
|
||||||
.then(({ data }) => data.visits);
|
.then(({ visits }) => visits);
|
||||||
|
|
||||||
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
public readonly getTagVisits = async (tag: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/tags/${tag}/visits`, 'GET', query).then(({ visits }) => visits);
|
||||||
.then(({ data }) => data.visits);
|
|
||||||
|
|
||||||
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
public readonly getDomainVisits = async (domain: string, query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>(`/domains/${domain}/visits`, 'GET', query).then(({ visits }) => visits);
|
||||||
.then(({ data }) => data.visits);
|
|
||||||
|
|
||||||
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
public readonly getOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/orphan', 'GET', query).then(({ visits }) => visits);
|
||||||
.then(({ data }) => data.visits);
|
|
||||||
|
|
||||||
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
public readonly getNonOrphanVisits = async (query?: Omit<ShlinkVisitsParams, 'domain'>): Promise<ShlinkVisits> =>
|
||||||
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query)
|
this.performRequest<{ visits: ShlinkVisits }>('/visits/non-orphan', 'GET', query).then(({ visits }) => visits);
|
||||||
.then(({ data }) => data.visits);
|
|
||||||
|
|
||||||
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
public readonly getVisitsOverview = async (): Promise<ShlinkVisitsOverview> =>
|
||||||
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits', 'GET')
|
this.performRequest<{ visits: ShlinkVisitsOverview }>('/visits').then(({ visits }) => visits);
|
||||||
.then(({ data }) => data.visits);
|
|
||||||
|
|
||||||
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
public readonly getShortUrl = async (shortCode: string, domain?: OptionalString): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain })
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'GET', { domain });
|
||||||
.then(({ data }) => data);
|
|
||||||
|
|
||||||
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
|
public readonly deleteShortUrl = async (shortCode: string, domain?: OptionalString): Promise<void> =>
|
||||||
this.performRequest(`/short-urls/${shortCode}`, 'DELETE', { domain })
|
this.performEmptyRequest(`/short-urls/${shortCode}`, 'DELETE', { domain });
|
||||||
.then(() => {});
|
|
||||||
|
|
||||||
public readonly updateShortUrl = async (
|
public readonly updateShortUrl = async (
|
||||||
shortCode: string,
|
shortCode: string,
|
||||||
domain: OptionalString,
|
domain: OptionalString,
|
||||||
edit: ShlinkShortUrlData,
|
edit: ShlinkShortUrlData,
|
||||||
): Promise<ShortUrl> =>
|
): Promise<ShortUrl> =>
|
||||||
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit).then(({ data }) => data);
|
this.performRequest<ShortUrl>(`/short-urls/${shortCode}`, 'PATCH', { domain }, edit);
|
||||||
|
|
||||||
public readonly listTags = async (): Promise<ShlinkTags> =>
|
public readonly listTags = async (): Promise<ShlinkTags> =>
|
||||||
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
this.performRequest<{ tags: ShlinkTagsResponse }>('/tags', 'GET', { withStats: 'true' })
|
||||||
.then((resp) => resp.data.tags)
|
.then(({ tags }) => tags)
|
||||||
.then(({ data, stats }) => ({ tags: data, stats }));
|
.then(({ data, stats }) => ({ tags: data, stats }));
|
||||||
|
|
||||||
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
|
public readonly deleteTags = async (tags: string[]): Promise<{ tags: string[] }> =>
|
||||||
this.performRequest('/tags', 'DELETE', { tags })
|
this.performEmptyRequest('/tags', 'DELETE', { tags }).then(() => ({ tags }));
|
||||||
.then(() => ({ tags }));
|
|
||||||
|
|
||||||
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
|
public readonly editTag = async (oldName: string, newName: string): Promise<{ oldName: string; newName: string }> =>
|
||||||
this.performRequest('/tags', 'PUT', {}, { oldName, newName })
|
this.performEmptyRequest('/tags', 'PUT', {}, { oldName, newName }).then(() => ({ oldName, newName }));
|
||||||
.then(() => ({ oldName, newName }));
|
|
||||||
|
|
||||||
public readonly health = async (): Promise<ShlinkHealth> =>
|
public readonly health = async (): Promise<ShlinkHealth> => this.performRequest<ShlinkHealth>('/health', 'GET');
|
||||||
this.performRequest<ShlinkHealth>('/health', 'GET')
|
|
||||||
.then((resp) => resp.data);
|
|
||||||
|
|
||||||
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
|
public readonly mercureInfo = async (): Promise<ShlinkMercureInfo> =>
|
||||||
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET')
|
this.performRequest<ShlinkMercureInfo>('/mercure-info', 'GET');
|
||||||
.then((resp) => resp.data);
|
|
||||||
|
|
||||||
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
public readonly listDomains = async (): Promise<ShlinkDomainsResponse> =>
|
||||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains);
|
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains').then(({ domains }) => domains);
|
||||||
|
|
||||||
public readonly editDomainRedirects = async (
|
public readonly editDomainRedirects = async (
|
||||||
domainRedirects: ShlinkEditDomainRedirects,
|
domainRedirects: ShlinkEditDomainRedirects,
|
||||||
): Promise<ShlinkDomainRedirects> =>
|
): Promise<ShlinkDomainRedirects> =>
|
||||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects);
|
||||||
|
|
||||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> =>
|
private readonly performRequest = async <T>(url: string, method = 'GET', query = {}, body?: object): Promise<T> =>
|
||||||
this.axios({
|
this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body)).catch(
|
||||||
|
this.handleFetchError(() => this.httpClient.fetchJson<T>(...this.toFetchParams(url, method, query, body))),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly performEmptyRequest = async (url: string, method = 'GET', query = {}, body?: object): Promise<void> =>
|
||||||
|
this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body)).catch(
|
||||||
|
this.handleFetchError(() => this.httpClient.fetchEmpty(...this.toFetchParams(url, method, query, body))),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly toFetchParams = (url: string, method: string, query = {}, body?: object): [string, RequestInit] => {
|
||||||
|
const normalizedQuery = stringifyQuery(rejectNilProps(query));
|
||||||
|
const stringifiedQuery = isEmpty(normalizedQuery) ? '' : `?${normalizedQuery}`;
|
||||||
|
|
||||||
|
return [`${buildShlinkBaseUrl(this.baseUrl, this.apiVersion)}${url}${stringifiedQuery}`, {
|
||||||
method,
|
method,
|
||||||
url: `${buildShlinkBaseUrl(this.baseUrl)}${url}`,
|
body: body && JSON.stringify(body),
|
||||||
headers: { 'X-Api-Key': this.apiKey },
|
headers: { 'X-Api-Key': this.apiKey },
|
||||||
params: rejectNilProps(query),
|
}];
|
||||||
data: body,
|
};
|
||||||
paramsSerializer: stringifyQuery,
|
|
||||||
});
|
private readonly handleFetchError = (retryFetch: Function) => (e: unknown) => {
|
||||||
|
if (!isRegularNotFound(parseApiError(e))) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we capture a not found error, let's assume this Shlink version does not support API v3, so we decrease to
|
||||||
|
// v2 and retry
|
||||||
|
this.apiVersion = 2;
|
||||||
|
return retryFetch();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,34 +1,32 @@
|
||||||
import { AxiosInstance } from 'axios';
|
import { hasServerData, ServerWithId } from '../../servers/data';
|
||||||
import { prop } from 'ramda';
|
|
||||||
import { hasServerData, SelectedServer, ServerWithId } from '../../servers/data';
|
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClient } from './ShlinkApiClient';
|
import { ShlinkApiClient } from './ShlinkApiClient';
|
||||||
|
import { HttpClient } from '../../common/services/HttpClient';
|
||||||
|
|
||||||
const apiClients: Record<string, ShlinkApiClient> = {};
|
const apiClients: Record<string, ShlinkApiClient> = {};
|
||||||
|
|
||||||
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||||
typeof getStateOrSelectedServer === 'function';
|
typeof getStateOrSelectedServer === 'function';
|
||||||
const getSelectedServerFromState = (getState: GetState): SelectedServer => prop('selectedServer', getState());
|
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
||||||
|
const { selectedServer } = getState();
|
||||||
export type ShlinkApiClientBuilder = (getStateOrSelectedServer: GetState | ServerWithId) => ShlinkApiClient;
|
if (!hasServerData(selectedServer)) {
|
||||||
|
|
||||||
export const buildShlinkApiClient = (axios: AxiosInstance): ShlinkApiClientBuilder => (
|
|
||||||
getStateOrSelectedServer: GetState | ServerWithId,
|
|
||||||
) => {
|
|
||||||
const server = isGetState(getStateOrSelectedServer)
|
|
||||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
|
||||||
: getStateOrSelectedServer;
|
|
||||||
|
|
||||||
if (!hasServerData(server)) {
|
|
||||||
throw new Error('There\'s no selected server or it is not found');
|
throw new Error('There\'s no selected server or it is not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { url, apiKey } = server;
|
return selectedServer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||||
|
const { url, apiKey } = isGetState(getStateOrSelectedServer)
|
||||||
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
|
: getStateOrSelectedServer;
|
||||||
const clientKey = `${url}_${apiKey}`;
|
const clientKey = `${url}_${apiKey}`;
|
||||||
|
|
||||||
if (!apiClients[clientKey]) {
|
if (!apiClients[clientKey]) {
|
||||||
apiClients[clientKey] = new ShlinkApiClient(axios, url, apiKey);
|
apiClients[clientKey] = new ShlinkApiClient(httpClient, url, apiKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return apiClients[clientKey];
|
return apiClients[clientKey];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ShlinkApiClientBuilder = ReturnType<typeof buildShlinkApiClient>;
|
||||||
|
|
|
@ -2,7 +2,7 @@ import Bottle from 'bottlejs';
|
||||||
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
|
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle) => {
|
const provideServices = (bottle: Bottle) => {
|
||||||
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'axios');
|
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -1,6 +0,0 @@
|
||||||
import { Action } from 'redux';
|
|
||||||
import { ProblemDetailsError } from './index';
|
|
||||||
|
|
||||||
export interface ApiErrorAction extends Action<string> {
|
|
||||||
errorData?: ProblemDetailsError;
|
|
||||||
}
|
|
54
src/api/types/errors.ts
Normal file
54
src/api/types/errors.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
export enum ErrorTypeV2 {
|
||||||
|
INVALID_ARGUMENT = 'INVALID_ARGUMENT',
|
||||||
|
INVALID_SHORT_URL_DELETION = 'INVALID_SHORT_URL_DELETION',
|
||||||
|
DOMAIN_NOT_FOUND = 'DOMAIN_NOT_FOUND',
|
||||||
|
FORBIDDEN_OPERATION = 'FORBIDDEN_OPERATION',
|
||||||
|
INVALID_URL = 'INVALID_URL',
|
||||||
|
INVALID_SLUG = 'INVALID_SLUG',
|
||||||
|
INVALID_SHORTCODE = 'INVALID_SHORTCODE',
|
||||||
|
TAG_CONFLICT = 'TAG_CONFLICT',
|
||||||
|
TAG_NOT_FOUND = 'TAG_NOT_FOUND',
|
||||||
|
MERCURE_NOT_CONFIGURED = 'MERCURE_NOT_CONFIGURED',
|
||||||
|
INVALID_AUTHORIZATION = 'INVALID_AUTHORIZATION',
|
||||||
|
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||||
|
NOT_FOUND = 'NOT_FOUND',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ErrorTypeV3 {
|
||||||
|
INVALID_ARGUMENT = 'https://shlink.io/api/error/invalid-data',
|
||||||
|
INVALID_SHORT_URL_DELETION = 'https://shlink.io/api/error/invalid-short-url-deletion',
|
||||||
|
DOMAIN_NOT_FOUND = 'https://shlink.io/api/error/domain-not-found',
|
||||||
|
FORBIDDEN_OPERATION = 'https://shlink.io/api/error/forbidden-tag-operation',
|
||||||
|
INVALID_URL = 'https://shlink.io/api/error/invalid-url',
|
||||||
|
INVALID_SLUG = 'https://shlink.io/api/error/non-unique-slug',
|
||||||
|
INVALID_SHORTCODE = 'https://shlink.io/api/error/short-url-not-found',
|
||||||
|
TAG_CONFLICT = 'https://shlink.io/api/error/tag-conflict',
|
||||||
|
TAG_NOT_FOUND = 'https://shlink.io/api/error/tag-not-found',
|
||||||
|
MERCURE_NOT_CONFIGURED = 'https://shlink.io/api/error/mercure-not-configured',
|
||||||
|
INVALID_AUTHORIZATION = 'https://shlink.io/api/error/missing-authentication',
|
||||||
|
INVALID_API_KEY = 'https://shlink.io/api/error/invalid-api-key',
|
||||||
|
NOT_FOUND = 'https://shlink.io/api/error/not-found',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemDetailsError {
|
||||||
|
type: string;
|
||||||
|
detail: string;
|
||||||
|
title: string;
|
||||||
|
status: number;
|
||||||
|
[extraProps: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvalidArgumentError extends ProblemDetailsError {
|
||||||
|
type: ErrorTypeV2.INVALID_ARGUMENT | ErrorTypeV3.INVALID_ARGUMENT;
|
||||||
|
invalidElements: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
||||||
|
type: 'INVALID_SHORTCODE_DELETION' | ErrorTypeV2.INVALID_SHORT_URL_DELETION | ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||||
|
threshold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegularNotFound extends ProblemDetailsError {
|
||||||
|
type: ErrorTypeV2.NOT_FOUND | ErrorTypeV3.NOT_FOUND;
|
||||||
|
status: 404;
|
||||||
|
}
|
|
@ -102,21 +102,3 @@ export interface ShlinkShortUrlsListParams {
|
||||||
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
export interface ShlinkShortUrlsListNormalizedParams extends Omit<ShlinkShortUrlsListParams, 'orderBy'> {
|
||||||
orderBy?: string;
|
orderBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
|
||||||
type: string;
|
|
||||||
detail: string;
|
|
||||||
title: string;
|
|
||||||
status: number;
|
|
||||||
[extraProps: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvalidArgumentError extends ProblemDetailsError {
|
|
||||||
type: 'INVALID_ARGUMENT';
|
|
||||||
invalidElements: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvalidShortUrlDeletion extends ProblemDetailsError {
|
|
||||||
type: 'INVALID_SHORTCODE_DELETION' | 'INVALID_SHORT_URL_DELETION';
|
|
||||||
threshold: number;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,24 @@
|
||||||
import { AxiosError } from 'axios';
|
import {
|
||||||
import { InvalidArgumentError, InvalidShortUrlDeletion, ProblemDetailsError } from '../types';
|
ErrorTypeV2,
|
||||||
|
ErrorTypeV3,
|
||||||
|
InvalidArgumentError,
|
||||||
|
InvalidShortUrlDeletion,
|
||||||
|
ProblemDetailsError,
|
||||||
|
RegularNotFound,
|
||||||
|
} from '../types/errors';
|
||||||
|
|
||||||
export const parseApiError = (e: AxiosError<ProblemDetailsError>) => e.response?.data;
|
const isProblemDetails = (e: unknown): e is ProblemDetailsError =>
|
||||||
|
!!e && typeof e === 'object' && ['type', 'detail', 'title', 'status'].every((prop) => prop in e);
|
||||||
|
|
||||||
|
export const parseApiError = (e: unknown): ProblemDetailsError | undefined => (isProblemDetails(e) ? e : undefined);
|
||||||
|
|
||||||
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
export const isInvalidArgumentError = (error?: ProblemDetailsError): error is InvalidArgumentError =>
|
||||||
error?.type === 'INVALID_ARGUMENT';
|
error?.type === ErrorTypeV2.INVALID_ARGUMENT || error?.type === ErrorTypeV3.INVALID_ARGUMENT;
|
||||||
|
|
||||||
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
export const isInvalidDeletionError = (error?: ProblemDetailsError): error is InvalidShortUrlDeletion =>
|
||||||
error?.type === 'INVALID_SHORTCODE_DELETION' || error?.type === 'INVALID_SHORT_URL_DELETION';
|
error?.type === 'INVALID_SHORTCODE_DELETION'
|
||||||
|
|| error?.type === ErrorTypeV2.INVALID_SHORT_URL_DELETION
|
||||||
|
|| error?.type === ErrorTypeV3.INVALID_SHORT_URL_DELETION;
|
||||||
|
|
||||||
|
export const isRegularNotFound = (error?: ProblemDetailsError): error is RegularNotFound =>
|
||||||
|
(error?.type === ErrorTypeV2.NOT_FOUND || error?.type === ErrorTypeV3.NOT_FOUND) && error?.status === 404;
|
||||||
|
|
|
@ -1,16 +1,14 @@
|
||||||
import { Action } from 'redux';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
|
|
||||||
export const APP_UPDATE_AVAILABLE = 'shlink/appUpdates/APP_UPDATE_AVAILABLE';
|
const { actions, reducer } = createSlice({
|
||||||
export const RESET_APP_UPDATE = 'shlink/appUpdates/RESET_APP_UPDATE';
|
name: 'shlink/appUpdates',
|
||||||
|
initialState: false,
|
||||||
|
reducers: {
|
||||||
|
appUpdateAvailable: () => true,
|
||||||
|
resetAppUpdate: () => false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const initialState = false;
|
export const { appUpdateAvailable, resetAppUpdate } = actions;
|
||||||
|
|
||||||
export default buildReducer<boolean, Action<string>>({
|
export const appUpdatesReducer = reducer;
|
||||||
[APP_UPDATE_AVAILABLE]: () => true,
|
|
||||||
[RESET_APP_UPDATE]: () => false,
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const appUpdateAvailable = buildActionCreator(APP_UPDATE_AVAILABLE);
|
|
||||||
|
|
||||||
export const resetAppUpdate = buildActionCreator(RESET_APP_UPDATE);
|
|
||||||
|
|
|
@ -1,25 +1,22 @@
|
||||||
import { Action } from 'redux';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
|
|
||||||
export const SIDEBAR_PRESENT = 'shlink/common/SIDEBAR_PRESENT';
|
|
||||||
export const SIDEBAR_NOT_PRESENT = 'shlink/common/SIDEBAR_NOT_PRESENT';
|
|
||||||
|
|
||||||
export interface Sidebar {
|
export interface Sidebar {
|
||||||
sidebarPresent: boolean;
|
sidebarPresent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SidebarRenderedAction = Action<string>;
|
|
||||||
type SidebarNotRenderedAction = Action<string>;
|
|
||||||
|
|
||||||
const initialState: Sidebar = {
|
const initialState: Sidebar = {
|
||||||
sidebarPresent: false,
|
sidebarPresent: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<Sidebar, SidebarRenderedAction & SidebarNotRenderedAction>({
|
const { actions, reducer } = createSlice({
|
||||||
[SIDEBAR_PRESENT]: () => ({ sidebarPresent: true }),
|
name: 'shlink/sidebar',
|
||||||
[SIDEBAR_NOT_PRESENT]: () => ({ sidebarPresent: false }),
|
initialState,
|
||||||
}, initialState);
|
reducers: {
|
||||||
|
sidebarPresent: () => ({ sidebarPresent: true }),
|
||||||
|
sidebarNotPresent: () => ({ sidebarPresent: false }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const sidebarPresent = buildActionCreator(SIDEBAR_PRESENT);
|
export const { sidebarPresent, sidebarNotPresent } = actions;
|
||||||
|
|
||||||
export const sidebarNotPresent = buildActionCreator(SIDEBAR_NOT_PRESENT);
|
export const sidebarReducer = reducer;
|
||||||
|
|
25
src/common/services/HttpClient.ts
Normal file
25
src/common/services/HttpClient.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import { Fetch } from '../../utils/types';
|
||||||
|
|
||||||
|
export class HttpClient {
|
||||||
|
constructor(private readonly fetch: Fetch) {}
|
||||||
|
|
||||||
|
public readonly fetchJson = <T>(url: string, options?: RequestInit): Promise<T> =>
|
||||||
|
this.fetch(url, options).then(async (resp) => {
|
||||||
|
const json = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw json;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly fetchEmpty = (url: string, options?: RequestInit): Promise<void> =>
|
||||||
|
this.fetch(url, options).then(async (resp) => {
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw await resp.json();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
public readonly fetchBlob = (url: string): Promise<Blob> => this.fetch(url).then((resp) => resp.blob());
|
||||||
|
}
|
|
@ -1,11 +1,11 @@
|
||||||
import { AxiosInstance } from 'axios';
|
|
||||||
import { saveUrl } from '../../utils/helpers/files';
|
import { saveUrl } from '../../utils/helpers/files';
|
||||||
|
import { HttpClient } from './HttpClient';
|
||||||
|
|
||||||
export class ImageDownloader {
|
export class ImageDownloader {
|
||||||
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
public constructor(private readonly httpClient: HttpClient, private readonly window: Window) {}
|
||||||
|
|
||||||
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||||
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
const data = await this.httpClient.fetchBlob(imgUrl);
|
||||||
const url = URL.createObjectURL(data);
|
const url = URL.createObjectURL(data);
|
||||||
|
|
||||||
saveUrl(this.window, url, filename);
|
saveUrl(this.window, url, filename);
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
import axios from 'axios';
|
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ScrollToTop } from '../ScrollToTop';
|
import { ScrollToTop } from '../ScrollToTop';
|
||||||
import { MainHeader } from '../MainHeader';
|
import { MainHeader } from '../MainHeader';
|
||||||
|
@ -12,14 +11,16 @@ import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServ
|
||||||
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
import { sidebarNotPresent, sidebarPresent } from '../reducers/sidebar';
|
||||||
import { ImageDownloader } from './ImageDownloader';
|
import { ImageDownloader } from './ImageDownloader';
|
||||||
import { ReportExporter } from './ReportExporter';
|
import { ReportExporter } from './ReportExporter';
|
||||||
|
import { HttpClient } from './HttpClient';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
// Services
|
// Services
|
||||||
bottle.constant('window', (global as any).window);
|
bottle.constant('window', (global as any).window);
|
||||||
bottle.constant('console', global.console);
|
bottle.constant('console', global.console);
|
||||||
bottle.constant('axios', axios);
|
bottle.constant('fetch', (global as any).fetch.bind(global));
|
||||||
|
|
||||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
bottle.service('HttpClient', HttpClient, 'fetch');
|
||||||
|
bottle.service('ImageDownloader', ImageDownloader, 'HttpClient', 'window');
|
||||||
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
bottle.service('ReportExporter', ReportExporter, 'window', 'jsonToCsv');
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
|
|
|
@ -1,14 +1,11 @@
|
||||||
import ReduxThunk from 'redux-thunk';
|
import { IContainer } from 'bottlejs';
|
||||||
import { applyMiddleware, compose, createStore } from 'redux';
|
|
||||||
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
import { save, load, RLSOptions } from 'redux-localstorage-simple';
|
||||||
import reducers from '../reducers';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import reducer from '../reducers';
|
||||||
import { migrateDeprecatedSettings } from '../settings/helpers';
|
import { migrateDeprecatedSettings } from '../settings/helpers';
|
||||||
import { ShlinkState } from './types';
|
import { ShlinkState } from './types';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
// eslint-disable-next-line no-mixed-operators
|
|
||||||
const composeEnhancers: Function = !isProduction && (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
|
|
||||||
|
|
||||||
const localStorageConfig: RLSOptions = {
|
const localStorageConfig: RLSOptions = {
|
||||||
states: ['settings', 'servers'],
|
states: ['settings', 'servers'],
|
||||||
namespace: 'shlink',
|
namespace: 'shlink',
|
||||||
|
@ -17,6 +14,12 @@ const localStorageConfig: RLSOptions = {
|
||||||
};
|
};
|
||||||
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
|
||||||
|
|
||||||
export const store = createStore(reducers, preloadedState, composeEnhancers(
|
export const setUpStore = (container: IContainer) => configureStore({
|
||||||
applyMiddleware(save(localStorageConfig), ReduxThunk),
|
devTools: !isProduction,
|
||||||
));
|
reducer: reducer(container),
|
||||||
|
preloadedState,
|
||||||
|
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
|
||||||
|
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
|
||||||
|
.prepend(container.selectServerListener.middleware)
|
||||||
|
.concat(save(localStorageConfig)),
|
||||||
|
});
|
||||||
|
|
|
@ -13,15 +13,15 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
|
||||||
import { TagVisits } from '../visits/reducers/tagVisits';
|
import { TagVisits } from '../visits/reducers/tagVisits';
|
||||||
import { DomainsList } from '../domains/reducers/domainsList';
|
import { DomainsList } from '../domains/reducers/domainsList';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { VisitsInfo } from '../visits/types';
|
|
||||||
import { Sidebar } from '../common/reducers/sidebar';
|
import { Sidebar } from '../common/reducers/sidebar';
|
||||||
import { DomainVisits } from '../visits/reducers/domainVisits';
|
import { DomainVisits } from '../visits/reducers/domainVisits';
|
||||||
|
import { VisitsInfo } from '../visits/reducers/types';
|
||||||
|
|
||||||
export interface ShlinkState {
|
export interface ShlinkState {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsList;
|
shortUrlsList: ShortUrlsList;
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreation: ShortUrlCreation;
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
shortUrlVisits: ShortUrlVisits;
|
shortUrlVisits: ShortUrlVisits;
|
||||||
|
|
|
@ -8,11 +8,12 @@ import { SelectedServer } from '../servers/data';
|
||||||
import { Domain } from './data';
|
import { Domain } from './data';
|
||||||
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
import { DomainStatusIcon } from './helpers/DomainStatusIcon';
|
||||||
import { DomainDropdown } from './helpers/DomainDropdown';
|
import { DomainDropdown } from './helpers/DomainDropdown';
|
||||||
|
import { EditDomainRedirects } from './reducers/domainRedirects';
|
||||||
|
|
||||||
interface DomainRowProps {
|
interface DomainRowProps {
|
||||||
domain: Domain;
|
domain: Domain;
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
checkDomainHealth: (domain: string) => void;
|
checkDomainHealth: (domain: string) => void;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { SearchField } from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { ShlinkDomainRedirects } from '../api/types';
|
import { EditDomainRedirects } from './reducers/domainRedirects';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { DomainsList } from './reducers/domainsList';
|
import { DomainsList } from './reducers/domainsList';
|
||||||
import { DomainRow } from './DomainRow';
|
import { DomainRow } from './DomainRow';
|
||||||
|
@ -12,7 +12,7 @@ import { DomainRow } from './DomainRow';
|
||||||
interface ManageDomainsProps {
|
interface ManageDomainsProps {
|
||||||
listDomains: Function;
|
listDomains: Function;
|
||||||
filterDomains: (searchTerm: string) => void;
|
filterDomains: (searchTerm: string) => void;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
checkDomainHealth: (domain: string) => void;
|
checkDomainHealth: (domain: string) => void;
|
||||||
domainsList: DomainsList;
|
domainsList: DomainsList;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
|
|
@ -7,14 +7,14 @@ import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
import { EditDomainRedirectsModal } from './EditDomainRedirectsModal';
|
||||||
import { Domain } from '../data';
|
import { Domain } from '../data';
|
||||||
import { ShlinkDomainRedirects } from '../../api/types';
|
import { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||||
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
|
import { supportsDefaultDomainRedirectsEdition, supportsDomainVisits } from '../../utils/helpers/features';
|
||||||
import { getServerId, SelectedServer } from '../../servers/data';
|
import { getServerId, SelectedServer } from '../../servers/data';
|
||||||
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
import { DEFAULT_DOMAIN } from '../../visits/reducers/domainVisits';
|
||||||
|
|
||||||
interface DomainDropdownProps {
|
interface DomainDropdownProps {
|
||||||
domain: Domain;
|
domain: Domain;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
import { ShlinkDomain } from '../../api/types';
|
||||||
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
import { InputFormGroup, InputFormGroupProps } from '../../utils/forms/InputFormGroup';
|
||||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||||
|
import { EditDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
|
||||||
interface EditDomainRedirectsModalProps {
|
interface EditDomainRedirectsModalProps {
|
||||||
domain: ShlinkDomain;
|
domain: ShlinkDomain;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
editDomainRedirects: (redirects: EditDomainRedirects) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
const FormGroup: FC<InputFormGroupProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||||
|
@ -30,10 +31,13 @@ export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||||
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
const [invalidShortUrlRedirect, setInvalidShortUrlRedirect] = useState(
|
||||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||||
);
|
);
|
||||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects({
|
||||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
domain: domain.domain,
|
||||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
redirects: {
|
||||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||||
|
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||||
|
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||||
|
},
|
||||||
}).then(toggle));
|
}).then(toggle));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,31 +1,22 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkDomainRedirects } from '../../api/types';
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
|
||||||
|
|
||||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
|
||||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
|
||||||
|
|
||||||
export interface EditDomainRedirectsAction extends Action<string> {
|
export interface EditDomainRedirects {
|
||||||
domain: string;
|
domain: string;
|
||||||
redirects: ShlinkDomainRedirects;
|
redirects: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const editDomainRedirects = (
|
||||||
domain: string,
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
) => createAsyncThunk(
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
EDIT_DOMAIN_REDIRECTS,
|
||||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
async ({ domain, redirects: providedRedirects }: EditDomainRedirects, { getState }): Promise<EditDomainRedirects> => {
|
||||||
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
const { editDomainRedirects: shlinkEditDomainRedirects } = buildShlinkApiClient(getState);
|
||||||
|
const redirects = await shlinkEditDomainRedirects({ domain, ...providedRedirects });
|
||||||
|
|
||||||
try {
|
return { domain, redirects };
|
||||||
const redirects = await shlinkEditDomainRedirects({ domain, ...domainRedirects });
|
},
|
||||||
|
);
|
||||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
|
||||||
} catch (e: any) {
|
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,20 +1,15 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createSlice, createAction, SliceCaseReducers, AsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { ProblemDetailsError, ShlinkDomainRedirects } from '../../api/types';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { ShlinkDomainRedirects } from '../../api/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
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 { Domain, DomainStatus } from '../data';
|
||||||
import { hasServerData } from '../../servers/data';
|
import { hasServerData } from '../../servers/data';
|
||||||
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
import { replaceAuthorityFromUri } from '../../utils/helpers/uri';
|
||||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
import { parseApiError } from '../../api/utils';
|
||||||
|
import { EditDomainRedirects } from './domainRedirects';
|
||||||
|
|
||||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
const REDUCER_PREFIX = 'shlink/domainsList';
|
||||||
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';
|
|
||||||
|
|
||||||
export interface DomainsList {
|
export interface DomainsList {
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
|
@ -25,16 +20,12 @@ export interface DomainsList {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListDomainsAction extends Action<string> {
|
interface ListDomains {
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
defaultRedirects?: ShlinkDomainRedirects;
|
defaultRedirects?: ShlinkDomainRedirects;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterDomainsAction extends Action<string> {
|
interface ValidateDomain {
|
||||||
searchTerm: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ValidateDomain extends Action<string> {
|
|
||||||
domain: string;
|
domain: string;
|
||||||
status: DomainStatus;
|
status: DomainStatus;
|
||||||
}
|
}
|
||||||
|
@ -46,83 +37,89 @@ const initialState: DomainsList = {
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DomainsCombinedAction = ListDomainsAction
|
export const replaceRedirectsOnDomain = ({ domain, redirects }: EditDomainRedirects) =>
|
||||||
& ApiErrorAction
|
|
||||||
& FilterDomainsAction
|
|
||||||
& EditDomainRedirectsAction
|
|
||||||
& ValidateDomain;
|
|
||||||
|
|
||||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
|
||||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, redirects });
|
||||||
|
|
||||||
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
export const replaceStatusOnDomain = (domain: string, status: DomainStatus) =>
|
||||||
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
(d: Domain): Domain => (d.domain !== domain ? d : { ...d, status });
|
||||||
|
|
||||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
export const domainsListReducerCreator = (
|
||||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
editDomainRedirects: AsyncThunk<EditDomainRedirects, any, any>,
|
||||||
[LIST_DOMAINS]: (_, { domains, defaultRedirects }) =>
|
|
||||||
({ ...initialState, domains, filteredDomains: domains, defaultRedirects }),
|
|
||||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
|
||||||
...state,
|
|
||||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm.toLowerCase())),
|
|
||||||
}),
|
|
||||||
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
|
||||||
...state,
|
|
||||||
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 (
|
|
||||||
dispatch: Dispatch,
|
|
||||||
getState: GetState,
|
|
||||||
) => {
|
) => {
|
||||||
dispatch({ type: LIST_DOMAINS_START });
|
const listDomains = createAsyncThunk(`${REDUCER_PREFIX}/listDomains`, async (_: void, { getState }): Promise<ListDomains> => {
|
||||||
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
const { listDomains: shlinkListDomains } = buildShlinkApiClient(getState);
|
||||||
|
const { data, defaultRedirects } = await shlinkListDomains();
|
||||||
|
|
||||||
try {
|
return {
|
||||||
const resp = await shlinkListDomains().then(({ data, defaultRedirects }) => ({
|
|
||||||
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
domains: data.map((domain): Domain => ({ ...domain, status: 'validating' })),
|
||||||
defaultRedirects,
|
defaultRedirects,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, ...resp });
|
const checkDomainHealth = createAsyncThunk(
|
||||||
} catch (e: any) {
|
`${REDUCER_PREFIX}/checkDomainHealth`,
|
||||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
async (domain: string, { getState }): Promise<ValidateDomain> => {
|
||||||
}
|
const { selectedServer } = getState();
|
||||||
};
|
|
||||||
|
if (!hasServerData(selectedServer)) {
|
||||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
return { domain, status: 'invalid' };
|
||||||
|
}
|
||||||
export const checkDomainHealth = (buildShlinkApiClient: ShlinkApiClientBuilder) => (domain: string) => async (
|
|
||||||
dispatch: Dispatch,
|
try {
|
||||||
getState: GetState,
|
const { url, ...rest } = selectedServer;
|
||||||
) => {
|
const { health } = buildShlinkApiClient({
|
||||||
const { selectedServer } = getState();
|
...rest,
|
||||||
|
url: replaceAuthorityFromUri(url, domain),
|
||||||
if (!hasServerData(selectedServer)) {
|
});
|
||||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
|
||||||
|
const { status } = await health();
|
||||||
return;
|
|
||||||
}
|
return { domain, status: status === 'pass' ? 'valid' : 'invalid' };
|
||||||
|
} catch (e) {
|
||||||
try {
|
return { domain, status: 'invalid' };
|
||||||
const { url, ...rest } = selectedServer;
|
}
|
||||||
const { health } = buildShlinkApiClient({
|
},
|
||||||
...rest,
|
);
|
||||||
url: replaceAuthorityFromUri(url, domain),
|
|
||||||
});
|
const filterDomains = createAction<string>(`${REDUCER_PREFIX}/filterDomains`);
|
||||||
|
|
||||||
const { status } = await health();
|
const { reducer } = createSlice<DomainsList, SliceCaseReducers<DomainsList>>({
|
||||||
|
name: REDUCER_PREFIX,
|
||||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: status === 'pass' ? 'valid' : 'invalid' });
|
initialState,
|
||||||
} catch (e) {
|
reducers: {},
|
||||||
dispatch<ValidateDomain>({ type: VALIDATE_DOMAIN, domain, status: 'invalid' });
|
extraReducers: (builder) => {
|
||||||
}
|
builder.addCase(listDomains.pending, () => ({ ...initialState, loading: true }));
|
||||||
|
builder.addCase(listDomains.rejected, (_, { error }) => (
|
||||||
|
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||||
|
));
|
||||||
|
builder.addCase(listDomains.fulfilled, (_, { payload }) => (
|
||||||
|
{ ...initialState, ...payload, filteredDomains: payload.domains }
|
||||||
|
));
|
||||||
|
|
||||||
|
builder.addCase(checkDomainHealth.fulfilled, ({ domains, filteredDomains, ...rest }, { payload }) => ({
|
||||||
|
...rest,
|
||||||
|
domains: domains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||||
|
filteredDomains: filteredDomains.map(replaceStatusOnDomain(payload.domain, payload.status)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.addCase(filterDomains, (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(payload.toLowerCase())),
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.addCase(editDomainRedirects.fulfilled, (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
domains: state.domains.map(replaceRedirectsOnDomain(payload)),
|
||||||
|
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(payload)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
reducer,
|
||||||
|
listDomains,
|
||||||
|
checkDomainHealth,
|
||||||
|
filterDomains,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
|
import { prop } from 'ramda';
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { checkDomainHealth, filterDomains, listDomains } from '../reducers/domainsList';
|
import { domainsListReducerCreator } from '../reducers/domainsList';
|
||||||
import { DomainSelector } from '../DomainSelector';
|
import { DomainSelector } from '../DomainSelector';
|
||||||
import { ManageDomains } from '../ManageDomains';
|
import { ManageDomains } from '../ManageDomains';
|
||||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||||
|
@ -16,11 +17,20 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
['listDomains', 'filterDomains', 'editDomainRedirects', 'checkDomainHealth'],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Reducer
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'domainsListReducerCreator',
|
||||||
|
domainsListReducerCreator,
|
||||||
|
'buildShlinkApiClient',
|
||||||
|
'editDomainRedirects',
|
||||||
|
);
|
||||||
|
bottle.serviceFactory('domainsListReducer', prop('reducer'), 'domainsListReducerCreator');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
bottle.serviceFactory('listDomains', prop('listDomains'), 'domainsListReducerCreator');
|
||||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
bottle.serviceFactory('filterDomains', prop('filterDomains'), 'domainsListReducerCreator');
|
||||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('checkDomainHealth', checkDomainHealth, 'buildShlinkApiClient');
|
bottle.serviceFactory('checkDomainHealth', prop('checkDomainHealth'), 'domainsListReducerCreator');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
@import './utils/base';
|
@import './utils/base';
|
||||||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||||
@import './common/react-tag-autocomplete.scss';
|
@import './common/react-tag-autocomplete.scss';
|
||||||
@import './theme/theme';
|
@import './utils/theme/theme';
|
||||||
|
@import './utils/mixins/text-ellipsis';
|
||||||
@import './utils/table/ResponsiveTable';
|
@import './utils/table/ResponsiveTable';
|
||||||
@import './utils/StickyCardPaginator';
|
@import './utils/StickyCardPaginator';
|
||||||
|
|
||||||
|
@ -39,6 +40,10 @@ a:not(.nav-link):not(.navbar-brand):not(.page-link):not(.highlight-card):not(.bt
|
||||||
background-color: $mainColor !important;
|
background-color: $mainColor !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
color: $lightTextColor;
|
||||||
|
}
|
||||||
|
|
||||||
.card-body,
|
.card-body,
|
||||||
.card-header,
|
.card-header,
|
||||||
.list-group-item {
|
.list-group-item {
|
||||||
|
@ -218,9 +223,7 @@ hr {
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-ellipsis {
|
.text-ellipsis {
|
||||||
text-overflow: ellipsis;
|
@include text-ellipsis();
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import pack from '../package.json';
|
import pack from '../package.json';
|
||||||
import { container } from './container';
|
import { container } from './container';
|
||||||
import { store } from './container/store';
|
import { setUpStore } from './container/store';
|
||||||
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
import { fixLeafletIcons } from './utils/helpers/leaflet';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
import 'chart.js/auto'; // TODO Import specific ones to reduce bundle size https://react-chartjs-2.js.org/docs/migration-to-v4/#tree-shaking
|
||||||
|
@ -14,6 +14,7 @@ import './index.scss';
|
||||||
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
// This overwrites icons used for leaflet maps, fixing some issues caused by webpack while processing the CSS
|
||||||
fixLeafletIcons();
|
fixLeafletIcons();
|
||||||
|
|
||||||
|
const store = setUpStore(container);
|
||||||
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
createRoot(document.getElementById('root')!).render( // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
|
|
@ -1,52 +1,44 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkMercureInfo } from '../../api/types';
|
import { ShlinkMercureInfo } from '../../api/types';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
export const GET_MERCURE_INFO_START = 'shlink/mercure/GET_MERCURE_INFO_START';
|
const REDUCER_PREFIX = 'shlink/mercure';
|
||||||
export const GET_MERCURE_INFO_ERROR = 'shlink/mercure/GET_MERCURE_INFO_ERROR';
|
|
||||||
export const GET_MERCURE_INFO = 'shlink/mercure/GET_MERCURE_INFO';
|
|
||||||
|
|
||||||
export interface MercureInfo {
|
export interface MercureInfo extends Partial<ShlinkMercureInfo> {
|
||||||
token?: string;
|
|
||||||
mercureHubUrl?: string;
|
|
||||||
interval?: number;
|
interval?: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GetMercureInfoAction = Action<string> & ShlinkMercureInfo & { interval?: number };
|
|
||||||
|
|
||||||
const initialState: MercureInfo = {
|
const initialState: MercureInfo = {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<MercureInfo, GetMercureInfoAction>({
|
export const mercureInfoReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||||
[GET_MERCURE_INFO_START]: (state) => ({ ...state, loading: true, error: false }),
|
const loadMercureInfo = createAsyncThunk(
|
||||||
[GET_MERCURE_INFO_ERROR]: (state) => ({ ...state, loading: false, error: true }),
|
`${REDUCER_PREFIX}/loadMercureInfo`,
|
||||||
[GET_MERCURE_INFO]: (_, action) => ({ ...action, loading: false, error: false }),
|
(_: void, { getState }): Promise<ShlinkMercureInfo> => {
|
||||||
}, initialState);
|
const { settings } = getState();
|
||||||
|
if (!settings.realTimeUpdates.enabled) {
|
||||||
|
throw new Error('Real time updates not enabled');
|
||||||
|
}
|
||||||
|
|
||||||
export const loadMercureInfo = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
return buildShlinkApiClient(getState).mercureInfo();
|
||||||
() => async (dispatch: Dispatch, getState: GetState) => {
|
},
|
||||||
dispatch({ type: GET_MERCURE_INFO_START });
|
);
|
||||||
|
|
||||||
const { settings } = getState();
|
const { reducer } = createSlice({
|
||||||
const { mercureInfo } = buildShlinkApiClient(getState);
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(loadMercureInfo.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||||
|
builder.addCase(loadMercureInfo.rejected, (state) => ({ ...state, loading: false, error: true }));
|
||||||
|
builder.addCase(loadMercureInfo.fulfilled, (_, { payload }) => ({ ...payload, loading: false, error: false }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (!settings.realTimeUpdates.enabled) {
|
return { loadMercureInfo, reducer };
|
||||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
};
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const info = await mercureInfo();
|
|
||||||
|
|
||||||
dispatch<GetMercureInfoAction>({ type: GET_MERCURE_INFO, interval: settings.realTimeUpdates.interval, ...info });
|
|
||||||
} catch (e) {
|
|
||||||
dispatch({ type: GET_MERCURE_INFO_ERROR });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,9 +1,14 @@
|
||||||
|
import { prop } from 'ramda';
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { loadMercureInfo } from '../reducers/mercureInfo';
|
import { mercureInfoReducerCreator } from '../reducers/mercureInfo';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle) => {
|
const provideServices = (bottle: Bottle) => {
|
||||||
|
// Reducer
|
||||||
|
bottle.serviceFactory('mercureInfoReducerCreator', mercureInfoReducerCreator, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('mercureInfoReducer', prop('reducer'), 'mercureInfoReducerCreator');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('loadMercureInfo', loadMercureInfo, 'buildShlinkApiClient');
|
bottle.serviceFactory('loadMercureInfo', prop('loadMercureInfo'), 'mercureInfoReducerCreator');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -1,47 +1,31 @@
|
||||||
import { combineReducers } from 'redux';
|
import { IContainer } from 'bottlejs';
|
||||||
import serversReducer from '../servers/reducers/servers';
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
import selectedServerReducer from '../servers/reducers/selectedServer';
|
import { serversReducer } from '../servers/reducers/servers';
|
||||||
import shortUrlsListReducer from '../short-urls/reducers/shortUrlsList';
|
import { settingsReducer } from '../settings/reducers/settings';
|
||||||
import shortUrlCreationReducer from '../short-urls/reducers/shortUrlCreation';
|
import { appUpdatesReducer } from '../app/reducers/appUpdates';
|
||||||
import shortUrlDeletionReducer from '../short-urls/reducers/shortUrlDeletion';
|
import { sidebarReducer } from '../common/reducers/sidebar';
|
||||||
import shortUrlEditionReducer from '../short-urls/reducers/shortUrlEdition';
|
|
||||||
import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits';
|
|
||||||
import tagVisitsReducer from '../visits/reducers/tagVisits';
|
|
||||||
import domainVisitsReducer from '../visits/reducers/domainVisits';
|
|
||||||
import orphanVisitsReducer from '../visits/reducers/orphanVisits';
|
|
||||||
import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits';
|
|
||||||
import shortUrlDetailReducer from '../short-urls/reducers/shortUrlDetail';
|
|
||||||
import tagsListReducer from '../tags/reducers/tagsList';
|
|
||||||
import tagDeleteReducer from '../tags/reducers/tagDelete';
|
|
||||||
import tagEditReducer from '../tags/reducers/tagEdit';
|
|
||||||
import mercureInfoReducer from '../mercure/reducers/mercureInfo';
|
|
||||||
import settingsReducer from '../settings/reducers/settings';
|
|
||||||
import domainsListReducer from '../domains/reducers/domainsList';
|
|
||||||
import visitsOverviewReducer from '../visits/reducers/visitsOverview';
|
|
||||||
import appUpdatesReducer from '../app/reducers/appUpdates';
|
|
||||||
import sidebarReducer from '../common/reducers/sidebar';
|
|
||||||
import { ShlinkState } from '../container/types';
|
import { ShlinkState } from '../container/types';
|
||||||
|
|
||||||
export default combineReducers<ShlinkState>({
|
export default (container: IContainer) => combineReducers<ShlinkState>({
|
||||||
servers: serversReducer,
|
servers: serversReducer,
|
||||||
selectedServer: selectedServerReducer,
|
selectedServer: container.selectedServerReducer,
|
||||||
shortUrlsList: shortUrlsListReducer,
|
shortUrlsList: container.shortUrlsListReducer,
|
||||||
shortUrlCreationResult: shortUrlCreationReducer,
|
shortUrlCreation: container.shortUrlCreationReducer,
|
||||||
shortUrlDeletion: shortUrlDeletionReducer,
|
shortUrlDeletion: container.shortUrlDeletionReducer,
|
||||||
shortUrlEdition: shortUrlEditionReducer,
|
shortUrlEdition: container.shortUrlEditionReducer,
|
||||||
shortUrlVisits: shortUrlVisitsReducer,
|
shortUrlDetail: container.shortUrlDetailReducer,
|
||||||
tagVisits: tagVisitsReducer,
|
shortUrlVisits: container.shortUrlVisitsReducer,
|
||||||
domainVisits: domainVisitsReducer,
|
tagVisits: container.tagVisitsReducer,
|
||||||
orphanVisits: orphanVisitsReducer,
|
domainVisits: container.domainVisitsReducer,
|
||||||
nonOrphanVisits: nonOrphanVisitsReducer,
|
orphanVisits: container.orphanVisitsReducer,
|
||||||
shortUrlDetail: shortUrlDetailReducer,
|
nonOrphanVisits: container.nonOrphanVisitsReducer,
|
||||||
tagsList: tagsListReducer,
|
tagsList: container.tagsListReducer,
|
||||||
tagDelete: tagDeleteReducer,
|
tagDelete: container.tagDeleteReducer,
|
||||||
tagEdit: tagEditReducer,
|
tagEdit: container.tagEditReducer,
|
||||||
mercureInfo: mercureInfoReducer,
|
mercureInfo: container.mercureInfoReducer,
|
||||||
settings: settingsReducer,
|
settings: settingsReducer,
|
||||||
domainsList: domainsListReducer,
|
domainsList: container.domainsListReducer,
|
||||||
visitsOverview: visitsOverviewReducer,
|
visitsOverview: container.visitsOverviewReducer,
|
||||||
appUpdated: appUpdatesReducer,
|
appUpdated: appUpdatesReducer,
|
||||||
sidebar: sidebarReducer,
|
sidebar: sidebarReducer,
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
interface CreateServerProps {
|
interface CreateServerProps {
|
||||||
createServer: (server: ServerWithId) => void;
|
createServers: (servers: ServerWithId[]) => void;
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
);
|
);
|
||||||
|
|
||||||
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => (
|
export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTimeoutToggle: TimeoutToggle) => (
|
||||||
{ servers, createServer }: CreateServerProps,
|
{ servers, createServers }: CreateServerProps,
|
||||||
) => {
|
) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
|
@ -43,7 +43,7 @@ export const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useTim
|
||||||
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
|
|
||||||
createServer({ ...serverData, id });
|
createServers([{ ...serverData, id }]);
|
||||||
navigate(`/server/${id}`);
|
navigate(`/server/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { FC } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
@ -18,14 +18,22 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
||||||
) => {
|
) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const closeModal = () => {
|
const doDelete = useRef<boolean>(false);
|
||||||
deleteServer(server);
|
const toggleAndDelete = () => {
|
||||||
|
doDelete.current = true;
|
||||||
toggle();
|
toggle();
|
||||||
|
};
|
||||||
|
const onClosed = () => {
|
||||||
|
if (!doDelete.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteServer(server);
|
||||||
redirectHome && navigate('/');
|
redirectHome && navigate('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||||
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||||
|
@ -38,7 +46,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
<Button color="link" onClick={toggle}>Cancel</Button>
|
||||||
<Button color="danger" onClick={() => closeModal()}>Delete</Button>
|
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,18 +1,17 @@
|
||||||
import { pipe, prop } from 'ramda';
|
|
||||||
import { AxiosInstance } from 'axios';
|
|
||||||
import { Dispatch } from 'redux';
|
|
||||||
import pack from '../../../package.json';
|
import pack from '../../../package.json';
|
||||||
import { hasServerData, ServerData } from '../data';
|
import { hasServerData, ServerData } from '../data';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
|
import { HttpClient } from '../../common/services/HttpClient';
|
||||||
|
|
||||||
const responseToServersList = pipe(
|
const responseToServersList = (data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []);
|
||||||
prop<any, any>('data'),
|
|
||||||
(data: any): ServerData[] => (Array.isArray(data) ? data.filter(hasServerData) : []),
|
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
|
||||||
|
'shlink/remoteServers/fetchServers',
|
||||||
|
async (_: void, { dispatch }): Promise<void> => {
|
||||||
|
const resp = await httpClient.fetchJson<any>(`${pack.homepage}/servers.json`);
|
||||||
|
const result = responseToServersList(resp);
|
||||||
|
|
||||||
|
dispatch(createServers(result));
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
|
||||||
const resp = await get(`${pack.homepage}/servers.json`);
|
|
||||||
const remoteList = responseToServersList(resp);
|
|
||||||
|
|
||||||
dispatch(createServers(remoteList));
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,23 +1,17 @@
|
||||||
|
import { createAction, createListenerMiddleware, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { identity, memoizeWith, pipe } from 'ramda';
|
import { identity, memoizeWith, pipe } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
|
||||||
import { SelectedServer } from '../data';
|
import { isReachableServer, SelectedServer } from '../data';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkHealth } from '../../api/types';
|
import { ShlinkHealth } from '../../api/types';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
|
|
||||||
export const SELECT_SERVER = 'shlink/selectedServer/SELECT_SERVER';
|
const REDUCER_PREFIX = 'shlink/selectedServer';
|
||||||
export const RESET_SELECTED_SERVER = 'shlink/selectedServer/RESET_SELECTED_SERVER';
|
|
||||||
|
|
||||||
export const MIN_FALLBACK_VERSION = '1.0.0';
|
export const MIN_FALLBACK_VERSION = '1.0.0';
|
||||||
export const MAX_FALLBACK_VERSION = '999.999.999';
|
export const MAX_FALLBACK_VERSION = '999.999.999';
|
||||||
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
export const LATEST_VERSION_CONSTRAINT = 'latest';
|
||||||
|
|
||||||
export interface SelectServerAction extends Action<string> {
|
|
||||||
selectedServer: SelectedServer;
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionToSemVer = pipe(
|
const versionToSemVer = pipe(
|
||||||
(version: string) => (version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version),
|
(version: string) => (version === LATEST_VERSION_CONSTRAINT ? MAX_FALLBACK_VERSION : version),
|
||||||
toSemVer(MIN_FALLBACK_VERSION),
|
toSemVer(MIN_FALLBACK_VERSION),
|
||||||
|
@ -33,53 +27,59 @@ const getServerVersion = memoizeWith(
|
||||||
|
|
||||||
const initialState: SelectedServer = null;
|
const initialState: SelectedServer = null;
|
||||||
|
|
||||||
export default buildReducer<SelectedServer, SelectServerAction>({
|
export const resetSelectedServer = createAction<void>(`${REDUCER_PREFIX}/resetSelectedServer`);
|
||||||
[RESET_SELECTED_SERVER]: () => initialState,
|
|
||||||
[SELECT_SERVER]: (_, { selectedServer }) => selectedServer,
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const resetSelectedServer = buildActionCreator(RESET_SELECTED_SERVER);
|
export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
|
`${REDUCER_PREFIX}/selectServer`,
|
||||||
|
async (serverId: string, { dispatch, getState }): Promise<SelectedServer> => {
|
||||||
|
dispatch(resetSelectedServer());
|
||||||
|
|
||||||
export const selectServer = (
|
const { servers } = getState();
|
||||||
buildShlinkApiClient: ShlinkApiClientBuilder,
|
const selectedServer = servers[serverId];
|
||||||
loadMercureInfo: () => Action,
|
|
||||||
) => (
|
|
||||||
serverId: string,
|
|
||||||
) => async (
|
|
||||||
dispatch: Dispatch,
|
|
||||||
getState: GetState,
|
|
||||||
) => {
|
|
||||||
dispatch(resetSelectedServer());
|
|
||||||
|
|
||||||
const { servers } = getState();
|
if (!selectedServer) {
|
||||||
const selectedServer = servers[serverId];
|
return { serverNotFound: true };
|
||||||
|
}
|
||||||
|
|
||||||
if (!selectedServer) {
|
try {
|
||||||
dispatch<SelectServerAction>({
|
const { health } = buildShlinkApiClient(selectedServer);
|
||||||
type: SELECT_SERVER,
|
const { version, printableVersion } = await getServerVersion(serverId, health);
|
||||||
selectedServer: { serverNotFound: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return {
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { health } = buildShlinkApiClient(selectedServer);
|
|
||||||
const { version, printableVersion } = await getServerVersion(serverId, health);
|
|
||||||
|
|
||||||
dispatch<SelectServerAction>({
|
|
||||||
type: SELECT_SERVER,
|
|
||||||
selectedServer: {
|
|
||||||
...selectedServer,
|
...selectedServer,
|
||||||
version,
|
version,
|
||||||
printableVersion,
|
printableVersion,
|
||||||
},
|
};
|
||||||
});
|
} catch (e) {
|
||||||
dispatch(loadMercureInfo());
|
return { ...selectedServer, serverNotReachable: true };
|
||||||
} catch (e) {
|
}
|
||||||
dispatch<SelectServerAction>({
|
},
|
||||||
type: SELECT_SERVER,
|
);
|
||||||
selectedServer: { ...selectedServer, serverNotReachable: true },
|
|
||||||
});
|
type SelectServerThunk = ReturnType<typeof selectServer>;
|
||||||
}
|
|
||||||
|
export const selectServerListener = (
|
||||||
|
selectServerThunk: SelectServerThunk,
|
||||||
|
loadMercureInfo: () => PayloadAction<any>, // TODO Consider setting actual type, if relevant
|
||||||
|
) => {
|
||||||
|
const listener = createListenerMiddleware();
|
||||||
|
|
||||||
|
listener.startListening({
|
||||||
|
actionCreator: selectServerThunk.fulfilled,
|
||||||
|
effect: ({ payload }, { dispatch }) => {
|
||||||
|
isReachableServer(payload) && dispatch(loadMercureInfo());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return listener;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({
|
||||||
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(resetSelectedServer, () => initialState);
|
||||||
|
builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -1,23 +1,14 @@
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
import { assoc, dissoc, fromPairs, map, pipe, reduce, toPairs } from 'ramda';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { Action } from 'redux';
|
|
||||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
|
||||||
|
|
||||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
interface EditServer {
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
|
||||||
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
|
||||||
|
|
||||||
export interface CreateServersAction extends Action<string> {
|
|
||||||
newServers: ServersMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteServerAction extends Action<string> {
|
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
serverData: Partial<ServerData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SetAutoConnectAction extends Action<string> {
|
interface SetAutoConnect {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
}
|
}
|
||||||
|
@ -32,50 +23,57 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
return assoc('id', uuid(), server);
|
return assoc('id', uuid(), server);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
|
||||||
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
|
||||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => (
|
|
||||||
!state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
|
|
||||||
),
|
|
||||||
[SET_AUTO_CONNECT]: (state, { serverId, autoConnect }) => {
|
|
||||||
if (!state[serverId]) {
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!autoConnect) {
|
|
||||||
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fromPairs(
|
|
||||||
toPairs(state).map(([evaluatedServerId, server]) => [
|
|
||||||
evaluatedServerId,
|
|
||||||
{ ...server, autoConnect: evaluatedServerId === serverId },
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
const serversListToMap = reduce<ServerWithId, ServersMap>((acc, server) => assoc(server.id, server, acc), {});
|
||||||
|
|
||||||
export const createServers = pipe(
|
export const { actions, reducer } = createSlice({
|
||||||
map(serverWithId),
|
name: 'shlink/servers',
|
||||||
serversListToMap,
|
initialState,
|
||||||
(newServers: ServersMap) => ({ type: CREATE_SERVERS, newServers }),
|
reducers: {
|
||||||
);
|
editServer: {
|
||||||
|
prepare: (serverId: string, serverData: Partial<ServerData>) => ({
|
||||||
|
payload: { serverId, serverData },
|
||||||
|
}),
|
||||||
|
reducer: (state, { payload }: PayloadAction<EditServer>) => {
|
||||||
|
const { serverId, serverData } = payload;
|
||||||
|
return (
|
||||||
|
!state[serverId] ? state : assoc(serverId, { ...state[serverId], ...serverData }, state)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deleteServer: (state, { payload }) => dissoc(payload.id, state),
|
||||||
|
setAutoConnect: {
|
||||||
|
prepare: ({ id: serverId }: ServerWithId, autoConnect: boolean) => ({
|
||||||
|
payload: { serverId, autoConnect },
|
||||||
|
}),
|
||||||
|
reducer: (state, { payload }: PayloadAction<SetAutoConnect>) => {
|
||||||
|
const { serverId, autoConnect } = payload;
|
||||||
|
if (!state[serverId]) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
export const createServer = (server: ServerWithId) => createServers([server]);
|
if (!autoConnect) {
|
||||||
|
return assoc(serverId, { ...state[serverId], autoConnect }, state);
|
||||||
|
}
|
||||||
|
|
||||||
export const editServer = (serverId: string, serverData: Partial<ServerData>) => ({
|
return fromPairs(
|
||||||
type: EDIT_SERVER,
|
toPairs(state).map(([evaluatedServerId, server]) => [
|
||||||
serverId,
|
evaluatedServerId,
|
||||||
serverData,
|
{ ...server, autoConnect: evaluatedServerId === serverId },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createServers: {
|
||||||
|
prepare: pipe(
|
||||||
|
map(serverWithId),
|
||||||
|
serversListToMap,
|
||||||
|
(payload: ServersMap) => ({ payload }),
|
||||||
|
),
|
||||||
|
reducer: (state, { payload: newServers }: PayloadAction<ServersMap>) => ({ ...state, ...newServers }),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
|
||||||
|
|
||||||
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
export const serversReducer = reducer;
|
||||||
type: SET_AUTO_CONNECT,
|
|
||||||
serverId: id,
|
|
||||||
autoConnect,
|
|
||||||
});
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { prop } from 'ramda';
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
import { CreateServer } from '../CreateServer';
|
import { CreateServer } from '../CreateServer';
|
||||||
import { ServersDropdown } from '../ServersDropdown';
|
import { ServersDropdown } from '../ServersDropdown';
|
||||||
|
@ -5,8 +6,13 @@ import { DeleteServerModal } from '../DeleteServerModal';
|
||||||
import { DeleteServerButton } from '../DeleteServerButton';
|
import { DeleteServerButton } from '../DeleteServerButton';
|
||||||
import { EditServer } from '../EditServer';
|
import { EditServer } from '../EditServer';
|
||||||
import { ImportServersBtn } from '../helpers/ImportServersBtn';
|
import { ImportServersBtn } from '../helpers/ImportServersBtn';
|
||||||
import { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
import {
|
||||||
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
resetSelectedServer,
|
||||||
|
selectedServerReducerCreator,
|
||||||
|
selectServer,
|
||||||
|
selectServerListener,
|
||||||
|
} from '../reducers/selectedServer';
|
||||||
|
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||||
import { fetchServers } from '../reducers/remoteServers';
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
|
@ -38,7 +44,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle');
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useTimeoutToggle');
|
||||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||||
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServer', 'resetSelectedServer']));
|
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer']));
|
||||||
|
|
||||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||||
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
|
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
|
||||||
|
@ -70,14 +76,18 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
|
||||||
bottle.serviceFactory('createServer', () => createServer);
|
|
||||||
bottle.serviceFactory('createServers', () => createServers);
|
bottle.serviceFactory('createServers', () => createServers);
|
||||||
bottle.serviceFactory('deleteServer', () => deleteServer);
|
bottle.serviceFactory('deleteServer', () => deleteServer);
|
||||||
bottle.serviceFactory('editServer', () => editServer);
|
bottle.serviceFactory('editServer', () => editServer);
|
||||||
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
|
||||||
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
|
||||||
|
|
||||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
|
||||||
|
// Reducers
|
||||||
|
bottle.serviceFactory('selectServerListener', selectServerListener, 'selectServer', 'loadMercureInfo');
|
||||||
|
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
|
||||||
|
bottle.serviceFactory('selectedServerReducer', prop('reducer'), 'selectedServerReducerCreator');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
import { Action } from 'redux';
|
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
|
||||||
import { dissoc, mergeDeepRight } from 'ramda';
|
import { mergeDeepRight } from 'ramda';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { RecursivePartial } from '../../utils/utils';
|
|
||||||
import { Theme } from '../../utils/theme';
|
import { Theme } from '../../utils/theme';
|
||||||
import { DateInterval } from '../../utils/dates/types';
|
import { DateInterval } from '../../utils/helpers/dateIntervals';
|
||||||
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||||
import { ShortUrlsOrder } from '../../short-urls/data';
|
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
|
||||||
|
|
||||||
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
export const DEFAULT_SHORT_URLS_ORDERING: ShortUrlsOrder = {
|
||||||
field: 'dateCreated',
|
field: 'dateCreated',
|
||||||
dir: 'DESC',
|
dir: 'DESC',
|
||||||
|
@ -78,45 +74,37 @@ const initialState: Settings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type SettingsAction = Action & Settings;
|
type SettingsAction = PayloadAction<Settings>;
|
||||||
|
type SettingsPrepareAction = PrepareAction<Settings>;
|
||||||
|
|
||||||
type PartialSettingsAction = Action & RecursivePartial<Settings>;
|
const commonReducer = (state: Settings, { payload }: SettingsAction) => mergeDeepRight(state, payload);
|
||||||
|
const toReducer = (prepare: SettingsPrepareAction) => ({ reducer: commonReducer, prepare });
|
||||||
|
const toPreparedAction: SettingsPrepareAction = (payload: Settings) => ({ payload });
|
||||||
|
|
||||||
export default buildReducer<Settings, SettingsAction>({
|
const { reducer, actions } = createSlice({
|
||||||
[SET_SETTINGS]: (state, action) => mergeDeepRight(state, dissoc('type', action)),
|
name: 'shlink/settings',
|
||||||
}, initialState);
|
initialState,
|
||||||
|
reducers: {
|
||||||
export const toggleRealTimeUpdates = (enabled: boolean): PartialSettingsAction => ({
|
toggleRealTimeUpdates: toReducer((enabled: boolean) => toPreparedAction({ realTimeUpdates: { enabled } })),
|
||||||
type: SET_SETTINGS,
|
setRealTimeUpdatesInterval: toReducer((interval: number) => toPreparedAction({ realTimeUpdates: { interval } })),
|
||||||
realTimeUpdates: { enabled },
|
setShortUrlCreationSettings: toReducer(
|
||||||
|
(shortUrlCreation: ShortUrlCreationSettings) => toPreparedAction({ shortUrlCreation }),
|
||||||
|
),
|
||||||
|
setShortUrlsListSettings: toReducer((shortUrlsList: ShortUrlsListSettings) => toPreparedAction({ shortUrlsList })),
|
||||||
|
setUiSettings: toReducer((ui: UiSettings) => toPreparedAction({ ui })),
|
||||||
|
setVisitsSettings: toReducer((visits: VisitsSettings) => toPreparedAction({ visits })),
|
||||||
|
setTagsSettings: toReducer((tags: TagsSettings) => toPreparedAction({ tags })),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const setRealTimeUpdatesInterval = (interval: number): PartialSettingsAction => ({
|
export const {
|
||||||
type: SET_SETTINGS,
|
toggleRealTimeUpdates,
|
||||||
realTimeUpdates: { interval },
|
setRealTimeUpdatesInterval,
|
||||||
});
|
setShortUrlCreationSettings,
|
||||||
|
setShortUrlsListSettings,
|
||||||
|
setUiSettings,
|
||||||
|
setVisitsSettings,
|
||||||
|
setTagsSettings,
|
||||||
|
} = actions;
|
||||||
|
|
||||||
export const setShortUrlCreationSettings = (settings: ShortUrlCreationSettings): PartialSettingsAction => ({
|
export const settingsReducer = reducer;
|
||||||
type: SET_SETTINGS,
|
|
||||||
shortUrlCreation: settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setShortUrlsListSettings = (settings: ShortUrlsListSettings): PartialSettingsAction => ({
|
|
||||||
type: SET_SETTINGS,
|
|
||||||
shortUrlsList: settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setUiSettings = (settings: UiSettings): PartialSettingsAction => ({
|
|
||||||
type: SET_SETTINGS,
|
|
||||||
ui: settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setVisitsSettings = (settings: VisitsSettings): PartialSettingsAction => ({
|
|
||||||
type: SET_SETTINGS,
|
|
||||||
visits: settings,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const setTagsSettings = (settings: TagsSettings): PartialSettingsAction => ({
|
|
||||||
type: SET_SETTINGS,
|
|
||||||
tags: settings,
|
|
||||||
});
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ export interface CreateShortUrlProps {
|
||||||
|
|
||||||
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
interface CreateShortUrlConnectProps extends CreateShortUrlProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
shortUrlCreationResult: ShortUrlCreation;
|
shortUrlCreation: ShortUrlCreation;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
createShortUrl: (data: ShortUrlData) => Promise<void>;
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
|
@ -38,7 +38,7 @@ export const CreateShortUrl = (
|
||||||
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
CreateShortUrlResult: FC<CreateShortUrlResultProps>,
|
||||||
) => ({
|
) => ({
|
||||||
createShortUrl,
|
createShortUrl,
|
||||||
shortUrlCreationResult,
|
shortUrlCreation,
|
||||||
resetCreateShortUrl,
|
resetCreateShortUrl,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
basicMode = false,
|
basicMode = false,
|
||||||
|
@ -50,7 +50,7 @@ export const CreateShortUrl = (
|
||||||
<>
|
<>
|
||||||
<ShortUrlForm
|
<ShortUrlForm
|
||||||
initialState={initialState}
|
initialState={initialState}
|
||||||
saving={shortUrlCreationResult.saving}
|
saving={shortUrlCreation.saving}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
mode={basicMode ? 'create-basic' : 'create'}
|
mode={basicMode ? 'create-basic' : 'create'}
|
||||||
onSave={async (data: ShortUrlData) => {
|
onSave={async (data: ShortUrlData) => {
|
||||||
|
@ -59,7 +59,7 @@ export const CreateShortUrl = (
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<CreateShortUrlResult
|
<CreateShortUrlResult
|
||||||
{...shortUrlCreationResult}
|
creation={shortUrlCreation}
|
||||||
resetCreateShortUrl={resetCreateShortUrl}
|
resetCreateShortUrl={resetCreateShortUrl}
|
||||||
canBeClosed={basicMode}
|
canBeClosed={basicMode}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,16 +6,15 @@ import { ExternalLink } from 'react-external-link';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { OptionalString } from '../utils/utils';
|
import { ShortUrlIdentifier } from './data';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
import { Message } from '../utils/Message';
|
import { Message } from '../utils/Message';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { useGoBack, useToggle } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { ShortUrlFormProps } from './ShortUrlForm';
|
import { ShortUrlFormProps } from './ShortUrlForm';
|
||||||
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
import { ShortUrlDetail } from './reducers/shortUrlDetail';
|
||||||
import { EditShortUrlData } from './data';
|
import { EditShortUrl as EditShortUrlInfo, ShortUrlEdition } from './reducers/shortUrlEdition';
|
||||||
import { ShortUrlEdition } from './reducers/shortUrlEdition';
|
|
||||||
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
import { shortUrlDataFromShortUrl, urlDecodeShortCode } from './helpers';
|
||||||
|
|
||||||
interface EditShortUrlConnectProps {
|
interface EditShortUrlConnectProps {
|
||||||
|
@ -23,8 +22,8 @@ interface EditShortUrlConnectProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
shortUrlEdition: ShortUrlEdition;
|
shortUrlEdition: ShortUrlEdition;
|
||||||
getShortUrlDetail: (shortCode: string, domain: OptionalString) => void;
|
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||||
editShortUrl: (shortUrl: string, domain: OptionalString, data: EditShortUrlData) => Promise<void>;
|
editShortUrl: (editShortUrl: EditShortUrlInfo) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
|
@ -39,16 +38,15 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
const params = useParams<{ shortCode: string }>();
|
const params = useParams<{ shortCode: string }>();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
const { loading, error, errorData, shortUrl } = shortUrlDetail;
|
||||||
const { saving, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
const { saving, saved, error: savingError, errorData: savingErrorData } = shortUrlEdition;
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
const initialState = useMemo(
|
const initialState = useMemo(
|
||||||
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
() => shortUrlDataFromShortUrl(shortUrl, shortUrlCreationSettings),
|
||||||
[shortUrl, shortUrlCreationSettings],
|
[shortUrl, shortUrlCreationSettings],
|
||||||
);
|
);
|
||||||
const [savingSucceeded,, isSuccessful, isNotSuccessful] = useToggle();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
params.shortCode && getShortUrlDetail(urlDecodeShortCode(params.shortCode), domain);
|
params.shortCode && getShortUrlDetail({ shortCode: urlDecodeShortCode(params.shortCode), domain });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
@ -88,18 +86,15 @@ export const EditShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>) => ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isNotSuccessful();
|
editShortUrl({ ...shortUrl, data: shortUrlData });
|
||||||
editShortUrl(shortUrl.shortCode, shortUrl.domain, shortUrlData)
|
|
||||||
.then(isSuccessful)
|
|
||||||
.catch(isNotSuccessful);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{savingError && (
|
{saved && savingError && (
|
||||||
<Result type="error" className="mt-3">
|
<Result type="error" className="mt-3">
|
||||||
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
<ShlinkApiError errorData={savingErrorData} fallbackMessage="An error occurred while updating short URL :(" />
|
||||||
</Result>
|
</Result>
|
||||||
)}
|
)}
|
||||||
{savingSucceeded && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
{saved && !savingError && <Result type="success" className="mt-3">Short URL properly edited.</Result>}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { InputType } from 'reactstrap/types/lib/Input';
|
||||||
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
import { Button, FormGroup, Input, Row } from 'reactstrap';
|
||||||
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { DateInput, DateInputProps } from '../utils/DateInput';
|
import { DateTimeInput, DateTimeInputProps } from '../utils/dates/DateTimeInput';
|
||||||
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
|
import { supportsCrawlableVisits, supportsForwardQuery } from '../utils/helpers/features';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
import { handleEventPreventingDefault, hasValue, OptionalString } from '../utils/utils';
|
||||||
|
@ -83,8 +83,8 @@ export const ShortUrlForm = (
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateInputProps> = {}) => (
|
const renderDateInput = (id: DateFields, placeholder: string, props: Partial<DateTimeInputProps> = {}) => (
|
||||||
<DateInput
|
<DateTimeInput
|
||||||
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
selected={shortUrlData[id] ? toDate(shortUrlData[id] as string | Date) : null}
|
||||||
placeholderText={placeholder}
|
placeholderText={placeholder}
|
||||||
isClearable
|
isClearable
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
@ -8,7 +7,7 @@ import classNames from 'classnames';
|
||||||
import { SearchField } from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
|
||||||
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { OrderDir } from '../utils/helpers/ordering';
|
import { OrderDir } from '../utils/helpers/ordering';
|
||||||
|
@ -27,8 +26,6 @@ export interface ShortUrlsFilteringProps {
|
||||||
shortUrlsAmount?: number;
|
shortUrlsAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
|
||||||
|
|
||||||
export const ShortUrlsFilteringBar = (
|
export const ShortUrlsFilteringBar = (
|
||||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
|
@ -74,10 +71,7 @@ export const ShortUrlsFilteringBar = (
|
||||||
<div className="col-lg-8 col-xl-6 mt-3">
|
<div className="col-lg-8 col-xl-6 mt-3">
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||||
startDate: dateOrNull(startDate),
|
|
||||||
endDate: dateOrNull(endDate),
|
|
||||||
}}
|
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -21,6 +21,11 @@ export interface ShortUrlData extends EditShortUrlData {
|
||||||
findIfExists?: boolean;
|
findIfExists?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShortUrlIdentifier {
|
||||||
|
shortCode: string;
|
||||||
|
domain?: OptionalString;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ShortUrl {
|
export interface ShortUrl {
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
shortUrl: string;
|
shortUrl: string;
|
||||||
|
@ -47,11 +52,6 @@ export interface ShortUrlModalProps {
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlIdentifier {
|
|
||||||
shortCode: string;
|
|
||||||
domain: OptionalString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
export const SHORT_URLS_ORDERABLE_FIELDS = {
|
||||||
dateCreated: 'Created at',
|
dateCreated: 'Created at',
|
||||||
shortCode: 'Short URL',
|
shortCode: 'Short URL',
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faCopy as copyIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faTimes as closeIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isNil } from 'ramda';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import CopyToClipboard from 'react-copy-to-clipboard';
|
import CopyToClipboard from 'react-copy-to-clipboard';
|
||||||
import { Tooltip } from 'reactstrap';
|
import { Tooltip } from 'reactstrap';
|
||||||
|
@ -11,15 +10,17 @@ import { Result } from '../../utils/Result';
|
||||||
import './CreateShortUrlResult.scss';
|
import './CreateShortUrlResult.scss';
|
||||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
export interface CreateShortUrlResultProps extends ShortUrlCreation {
|
export interface CreateShortUrlResultProps {
|
||||||
|
creation: ShortUrlCreation;
|
||||||
resetCreateShortUrl: () => void;
|
resetCreateShortUrl: () => void;
|
||||||
canBeClosed?: boolean;
|
canBeClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
||||||
{ error, errorData, result, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
{ creation, resetCreateShortUrl, canBeClosed = false }: CreateShortUrlResultProps,
|
||||||
) => {
|
) => {
|
||||||
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
|
const [showCopyTooltip, setShowCopyTooltip] = useTimeoutToggle();
|
||||||
|
const { error, saved } = creation;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetCreateShortUrl();
|
resetCreateShortUrl();
|
||||||
|
@ -29,16 +30,16 @@ export const CreateShortUrlResult = (useTimeoutToggle: TimeoutToggle) => (
|
||||||
return (
|
return (
|
||||||
<Result type="error" className="mt-3">
|
<Result type="error" className="mt-3">
|
||||||
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
{canBeClosed && <FontAwesomeIcon icon={closeIcon} className="float-end pointer" onClick={resetCreateShortUrl} />}
|
||||||
<ShlinkApiError errorData={errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
<ShlinkApiError errorData={creation.errorData} fallbackMessage="An error occurred while creating the URL :(" />
|
||||||
</Result>
|
</Result>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNil(result)) {
|
if (!saved) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { shortUrl } = result;
|
const { shortUrl } = creation.result;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Result type="success" className="mt-3">
|
<Result type="success" className="mt-3">
|
||||||
|
|
|
@ -1,38 +1,41 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { identity, pipe } from 'ramda';
|
import { pipe } from 'ramda';
|
||||||
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
import { ShortUrlDeletion } from '../reducers/shortUrlDeletion';
|
||||||
import { ShortUrlModalProps } from '../data';
|
import { ShortUrlIdentifier, ShortUrlModalProps } from '../data';
|
||||||
import { handleEventPreventingDefault, OptionalString } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { Result } from '../../utils/Result';
|
import { Result } from '../../utils/Result';
|
||||||
import { isInvalidDeletionError } from '../../api/utils';
|
import { isInvalidDeletionError } from '../../api/utils';
|
||||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
|
|
||||||
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
interface DeleteShortUrlModalConnectProps extends ShortUrlModalProps {
|
||||||
shortUrlDeletion: ShortUrlDeletion;
|
shortUrlDeletion: ShortUrlDeletion;
|
||||||
deleteShortUrl: (shortCode: string, domain: OptionalString) => Promise<void>;
|
deleteShortUrl: (shortUrl: ShortUrlIdentifier) => Promise<void>;
|
||||||
|
shortUrlDeleted: (shortUrl: ShortUrlIdentifier) => void;
|
||||||
resetDeleteShortUrl: () => void;
|
resetDeleteShortUrl: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteShortUrlModal = (
|
const DELETION_PATTERN = 'delete';
|
||||||
{ shortUrl, toggle, isOpen, shortUrlDeletion, resetDeleteShortUrl, deleteShortUrl }: DeleteShortUrlModalConnectProps,
|
|
||||||
) => {
|
export const DeleteShortUrlModal = ({
|
||||||
|
shortUrl,
|
||||||
|
toggle,
|
||||||
|
isOpen,
|
||||||
|
shortUrlDeletion,
|
||||||
|
resetDeleteShortUrl,
|
||||||
|
deleteShortUrl,
|
||||||
|
shortUrlDeleted,
|
||||||
|
}: DeleteShortUrlModalConnectProps) => {
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
useEffect(() => resetDeleteShortUrl, []);
|
useEffect(() => resetDeleteShortUrl, []);
|
||||||
|
|
||||||
const { error, errorData } = shortUrlDeletion;
|
const { loading, error, deleted, errorData } = shortUrlDeletion;
|
||||||
const close = pipe(resetDeleteShortUrl, toggle);
|
const close = pipe(resetDeleteShortUrl, toggle);
|
||||||
const handleDeleteUrl = handleEventPreventingDefault(() => {
|
const handleDeleteUrl = handleEventPreventingDefault(() => deleteShortUrl(shortUrl).then(toggle));
|
||||||
const { shortCode, domain } = shortUrl;
|
|
||||||
|
|
||||||
deleteShortUrl(shortCode, domain)
|
|
||||||
.then(toggle)
|
|
||||||
.catch(identity);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={close} centered>
|
<Modal isOpen={isOpen} toggle={close} centered onClosed={() => deleted && shortUrlDeleted(shortUrl)}>
|
||||||
<form onSubmit={handleDeleteUrl}>
|
<form onSubmit={handleDeleteUrl}>
|
||||||
<ModalHeader toggle={close}>
|
<ModalHeader toggle={close}>
|
||||||
<span className="text-danger">Delete short URL</span>
|
<span className="text-danger">Delete short URL</span>
|
||||||
|
@ -40,12 +43,12 @@ export const DeleteShortUrlModal = (
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
<p><b className="text-danger">Caution!</b> You are about to delete a short URL.</p>
|
||||||
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
<p>This action cannot be undone. Once you have deleted it, all the visits stats will be lost.</p>
|
||||||
<p>Write <b>{shortUrl.shortCode}</b> to confirm deletion.</p>
|
<p>Write <b>{DELETION_PATTERN}</b> to confirm deletion.</p>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
placeholder={`Insert the short code (${shortUrl.shortCode})`}
|
placeholder={`Insert ${DELETION_PATTERN}`}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
@ -61,9 +64,9 @@ export const DeleteShortUrlModal = (
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
disabled={inputValue !== shortUrl.shortCode || shortUrlDeletion.loading}
|
disabled={inputValue !== DELETION_PATTERN || loading}
|
||||||
>
|
>
|
||||||
{shortUrlDeletion.loading ? 'Deleting...' : 'Delete'}
|
{loading ? 'Deleting...' : 'Delete'}
|
||||||
</button>
|
</button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import '../../utils/base';
|
@import '../../utils/base';
|
||||||
|
@import '../../utils/mixins/text-ellipsis';
|
||||||
@import '../../utils/mixins/vertical-align';
|
@import '../../utils/mixins/vertical-align';
|
||||||
|
|
||||||
.short-urls-row__cell.short-urls-row__cell {
|
.short-urls-row__cell.short-urls-row__cell {
|
||||||
|
@ -13,6 +14,26 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.short-urls-row__cell--indivisible {
|
||||||
|
@media (min-width: $lgMin) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.short-urls-row__short-url-wrapper {
|
||||||
|
@media (max-width: $mdMax) {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: $lgMin) {
|
||||||
|
@include text-ellipsis();
|
||||||
|
|
||||||
|
vertical-align: bottom;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 18rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.short-urls-row__copy-hint {
|
.short-urls-row__copy-hint {
|
||||||
@include vertical-align(translateX(10px));
|
@include vertical-align(translateX(10px));
|
||||||
|
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { Tag } from '../../tags/helpers/Tag';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||||
import { ShortUrl } from '../data';
|
import { ShortUrl } from '../data';
|
||||||
import { Time } from '../../utils/Time';
|
import { Time } from '../../utils/dates/Time';
|
||||||
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
import { ShortUrlVisitsCount } from './ShortUrlVisitsCount';
|
||||||
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
import { ShortUrlsRowMenuProps } from './ShortUrlsRowMenu';
|
||||||
import './ShortUrlsRow.scss';
|
import './ShortUrlsRow.scss';
|
||||||
|
@ -43,11 +43,8 @@ export const ShortUrlsRow = (
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFirstRun.current) {
|
!isFirstRun.current && setActive();
|
||||||
isFirstRun.current = false;
|
isFirstRun.current = false;
|
||||||
} else {
|
|
||||||
setActive();
|
|
||||||
}
|
|
||||||
}, [shortUrl.visitsCount]);
|
}, [shortUrl.visitsCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -56,15 +53,20 @@ export const ShortUrlsRow = (
|
||||||
<Time date={shortUrl.dateCreated} />
|
<Time date={shortUrl.dateCreated} />
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||||
<span className="indivisible short-urls-row__cell--relative">
|
<span className="short-urls-row__cell--relative short-urls-row__cell--indivisible">
|
||||||
<ExternalLink href={shortUrl.shortUrl} />
|
<span className="short-urls-row__short-url-wrapper">
|
||||||
|
<ExternalLink href={shortUrl.shortUrl} />
|
||||||
|
</span>
|
||||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||||
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
<span className="badge bg-warning text-black short-urls-row__copy-hint" hidden={!copiedToClipboard}>
|
||||||
Copied short URL!
|
Copied short URL!
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
<td
|
||||||
|
className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break"
|
||||||
|
data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}
|
||||||
|
>
|
||||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||||
</td>
|
</td>
|
||||||
{shortUrl.title && (
|
{shortUrl.title && (
|
||||||
|
|
|
@ -24,8 +24,8 @@ export const ShortUrlsRowMenu = (
|
||||||
QrCodeModal: ShortUrlModal,
|
QrCodeModal: ShortUrlModal,
|
||||||
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
|
) => ({ shortUrl, selectedServer }: ShortUrlsRowMenuProps) => {
|
||||||
const [isOpen, toggle] = useToggle();
|
const [isOpen, toggle] = useToggle();
|
||||||
const [isQrModalOpen, toggleQrCode] = useToggle();
|
const [isQrModalOpen,, openQrCodeModal, closeQrCodeModal] = useToggle();
|
||||||
const [isDeleteModalOpen, toggleDelete] = useToggle();
|
const [isDeleteModalOpen,, openDeleteModal, closeDeleteModal] = useToggle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||||
|
@ -37,17 +37,17 @@ export const ShortUrlsRowMenu = (
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem onClick={toggleQrCode}>
|
<DropdownItem onClick={openQrCodeModal}>
|
||||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={closeQrCodeModal} />
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="dropdown-item--danger" onClick={openDeleteModal}>
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={closeDeleteModal} />
|
||||||
</DropdownBtnMenu>
|
</DropdownBtnMenu>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||||
import { TagsFilteringMode } from '../../api/types';
|
import { TagsFilteringMode } from '../../api/types';
|
||||||
|
|
||||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
|
||||||
|
|
||||||
interface ShortUrlsQueryCommon {
|
interface ShortUrlsQueryCommon {
|
||||||
search?: string;
|
search?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
@ -25,35 +23,36 @@ interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||||
|
|
||||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const { search } = useLocation();
|
||||||
const params = useParams<{ serverId: string }>();
|
const { serverId = '' } = useParams<{ serverId: string }>();
|
||||||
|
|
||||||
const query = useMemo(
|
const filtering = useMemo(
|
||||||
pipe(
|
pipe(
|
||||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
() => parseQuery<ShortUrlsQuery>(search),
|
||||||
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||||
const parsedTags = tags?.split(',') ?? [];
|
const parsedTags = tags?.split(',') ?? [];
|
||||||
|
|
||||||
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
[location.search],
|
[search],
|
||||||
);
|
);
|
||||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||||
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra };
|
||||||
const normalizedQuery: ShortUrlsQuery = {
|
const query: ShortUrlsQuery = {
|
||||||
...mergedQuery,
|
...mergedFiltering,
|
||||||
orderBy: orderBy && orderToString(orderBy),
|
orderBy: orderBy && orderToString(orderBy),
|
||||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||||
};
|
};
|
||||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
const stringifiedQuery = stringifyQuery(query);
|
||||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||||
|
|
||||||
navigate(`/server/${params.serverId ?? ''}/list-short-urls/1${queryString}`);
|
navigate(`/server/${serverId}/list-short-urls/1${queryString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [query, toFirstPageWithExtra];
|
return [filtering, toFirstPageWithExtra];
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,57 +1,72 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShortUrl, ShortUrlData } from '../data';
|
import { ShortUrl, ShortUrlData } from '../data';
|
||||||
import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
const REDUCER_PREFIX = 'shlink/shortUrlCreation';
|
||||||
export const CREATE_SHORT_URL_ERROR = 'shlink/createShortUrl/CREATE_SHORT_URL_ERROR';
|
|
||||||
export const CREATE_SHORT_URL = 'shlink/createShortUrl/CREATE_SHORT_URL';
|
|
||||||
export const RESET_CREATE_SHORT_URL = 'shlink/createShortUrl/RESET_CREATE_SHORT_URL';
|
|
||||||
|
|
||||||
export interface ShortUrlCreation {
|
export type ShortUrlCreation = {
|
||||||
result: ShortUrl | null;
|
saving: false;
|
||||||
saving: boolean;
|
saved: false;
|
||||||
error: boolean;
|
error: false;
|
||||||
|
} | {
|
||||||
|
saving: true;
|
||||||
|
saved: false;
|
||||||
|
error: false;
|
||||||
|
} | {
|
||||||
|
saving: false;
|
||||||
|
saved: false;
|
||||||
|
error: true;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
} | {
|
||||||
|
|
||||||
export interface CreateShortUrlAction extends Action<string> {
|
|
||||||
result: ShortUrl;
|
result: ShortUrl;
|
||||||
}
|
saving: false;
|
||||||
|
saved: true;
|
||||||
|
error: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateShortUrlAction = PayloadAction<ShortUrl>;
|
||||||
|
|
||||||
const initialState: ShortUrlCreation = {
|
const initialState: ShortUrlCreation = {
|
||||||
result: null,
|
|
||||||
saving: false,
|
saving: false,
|
||||||
|
saved: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
`${REDUCER_PREFIX}/createShortUrl`,
|
||||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
(data: ShortUrlData, { getState }): Promise<ShortUrl> => {
|
||||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState);
|
||||||
[RESET_CREATE_SHORT_URL]: () => initialState,
|
return shlinkCreateShortUrl(data);
|
||||||
}, initialState);
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (data: ShortUrlData) => async (
|
export const shortUrlCreationReducerCreator = (createShortUrlThunk: ReturnType<typeof createShortUrl>) => {
|
||||||
dispatch: Dispatch,
|
const { reducer, actions } = createSlice({
|
||||||
getState: GetState,
|
name: REDUCER_PREFIX,
|
||||||
) => {
|
initialState: initialState as ShortUrlCreation, // Without this casting it infers type ShortUrlCreationWaiting
|
||||||
dispatch({ type: CREATE_SHORT_URL_START });
|
reducers: {
|
||||||
const { createShortUrl: shlinkCreateShortUrl } = buildShlinkApiClient(getState);
|
resetCreateShortUrl: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(createShortUrlThunk.pending, () => ({ saving: true, saved: false, error: false }));
|
||||||
|
builder.addCase(
|
||||||
|
createShortUrlThunk.rejected,
|
||||||
|
(_, { error }) => ({ saving: false, saved: false, error: true, errorData: parseApiError(error) }),
|
||||||
|
);
|
||||||
|
builder.addCase(
|
||||||
|
createShortUrlThunk.fulfilled,
|
||||||
|
(_, { payload: result }) => ({ result, saving: false, saved: true, error: false }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
const { resetCreateShortUrl } = actions;
|
||||||
const result = await shlinkCreateShortUrl(data);
|
|
||||||
|
|
||||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
return {
|
||||||
} catch (e: any) {
|
reducer,
|
||||||
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
resetCreateShortUrl,
|
||||||
|
};
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetCreateShortUrl = buildActionCreator(RESET_CREATE_SHORT_URL);
|
|
||||||
|
|
|
@ -1,56 +1,60 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
import { ShortUrl, ShortUrlIdentifier } from '../data';
|
||||||
|
|
||||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
const REDUCER_PREFIX = 'shlink/shortUrlDeletion';
|
||||||
export const DELETE_SHORT_URL_ERROR = 'shlink/deleteShortUrl/DELETE_SHORT_URL_ERROR';
|
|
||||||
export const SHORT_URL_DELETED = 'shlink/deleteShortUrl/SHORT_URL_DELETED';
|
|
||||||
export const RESET_DELETE_SHORT_URL = 'shlink/deleteShortUrl/RESET_DELETE_SHORT_URL';
|
|
||||||
|
|
||||||
export interface ShortUrlDeletion {
|
export interface ShortUrlDeletion {
|
||||||
shortCode: string;
|
shortCode: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
deleted: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteShortUrlAction extends Action<string> {
|
|
||||||
shortCode: string;
|
|
||||||
domain?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDeletion = {
|
const initialState: ShortUrlDeletion = {
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
deleted: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
`${REDUCER_PREFIX}/deleteShortUrl`,
|
||||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrlIdentifier> => {
|
||||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
|
||||||
[RESET_DELETE_SHORT_URL]: () => initialState,
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
|
||||||
shortCode: string,
|
|
||||||
domain?: string | null,
|
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
|
||||||
dispatch({ type: DELETE_SHORT_URL_START });
|
|
||||||
const { deleteShortUrl: shlinkDeleteShortUrl } = buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await shlinkDeleteShortUrl(shortCode, domain);
|
await shlinkDeleteShortUrl(shortCode, domain);
|
||||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
return { shortCode, domain };
|
||||||
} catch (e: any) {
|
},
|
||||||
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
);
|
||||||
|
|
||||||
throw e;
|
export const shortUrlDeleted = createAction<ShortUrl>(`${REDUCER_PREFIX}/shortUrlDeleted`);
|
||||||
}
|
|
||||||
|
export const shortUrlDeletionReducerCreator = (deleteShortUrlThunk: ReturnType<typeof deleteShortUrl>) => {
|
||||||
|
const { actions, reducer } = createSlice({
|
||||||
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
resetDeleteShortUrl: () => initialState,
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(
|
||||||
|
deleteShortUrlThunk.pending,
|
||||||
|
(state) => ({ ...state, loading: true, error: false, deleted: false }),
|
||||||
|
);
|
||||||
|
builder.addCase(deleteShortUrlThunk.rejected, (state, { error }) => (
|
||||||
|
{ ...state, errorData: parseApiError(error), loading: false, error: true, deleted: false }
|
||||||
|
));
|
||||||
|
builder.addCase(deleteShortUrlThunk.fulfilled, (state, { payload }) => (
|
||||||
|
{ ...state, shortCode: payload.shortCode, loading: false, error: false, deleted: true }
|
||||||
|
));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { resetDeleteShortUrl } = actions;
|
||||||
|
|
||||||
|
return { reducer, resetDeleteShortUrl };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetDeleteShortUrl = buildActionCreator(RESET_DELETE_SHORT_URL);
|
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { ShortUrl } from '../data';
|
import { ShortUrl, ShortUrlIdentifier } from '../data';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { OptionalString } from '../../utils/utils';
|
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
const REDUCER_PREFIX = 'shlink/shortUrlDetail';
|
||||||
export const GET_SHORT_URL_DETAIL_ERROR = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_ERROR';
|
|
||||||
export const GET_SHORT_URL_DETAIL = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL';
|
|
||||||
|
|
||||||
export interface ShortUrlDetail {
|
export interface ShortUrlDetail {
|
||||||
shortUrl?: ShortUrl;
|
shortUrl?: ShortUrl;
|
||||||
|
@ -20,35 +15,36 @@ export interface ShortUrlDetail {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlDetailAction extends Action<string> {
|
export type ShortUrlDetailAction = PayloadAction<ShortUrl>;
|
||||||
shortUrl: ShortUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: ShortUrlDetail = {
|
const initialState: ShortUrlDetail = {
|
||||||
loading: false,
|
loading: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
export const shortUrlDetailReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
const getShortUrlDetail = createAsyncThunk(
|
||||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
`${REDUCER_PREFIX}/getShortUrlDetail`,
|
||||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
async ({ shortCode, domain }: ShortUrlIdentifier, { getState }): Promise<ShortUrl> => {
|
||||||
}, initialState);
|
const { shortUrlsList } = getState();
|
||||||
|
const alreadyLoaded = shortUrlsList?.shortUrls?.data.find((url) => shortUrlMatches(url, shortCode, domain));
|
||||||
|
|
||||||
export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
return alreadyLoaded ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||||
shortCode: string,
|
},
|
||||||
domain: OptionalString,
|
);
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
|
||||||
dispatch({ type: GET_SHORT_URL_DETAIL_START });
|
|
||||||
|
|
||||||
try {
|
const { reducer } = createSlice({
|
||||||
const { shortUrlsList } = getState();
|
name: REDUCER_PREFIX,
|
||||||
const shortUrl = shortUrlsList?.shortUrls?.data.find(
|
initialState,
|
||||||
(url) => shortUrlMatches(url, shortCode, domain),
|
reducers: {},
|
||||||
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(getShortUrlDetail.pending, () => ({ loading: true, error: false }));
|
||||||
|
builder.addCase(getShortUrlDetail.rejected, (_, { error }) => (
|
||||||
|
{ loading: false, error: true, errorData: parseApiError(error) }
|
||||||
|
));
|
||||||
|
builder.addCase(getShortUrlDetail.fulfilled, (_, { payload: shortUrl }) => ({ ...initialState, shortUrl }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
return { reducer, getShortUrlDetail };
|
||||||
} catch (e: any) {
|
|
||||||
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,55 +1,53 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
import { EditShortUrlData, ShortUrl, ShortUrlIdentifier } from '../data';
|
||||||
import { OptionalString } from '../../utils/utils';
|
|
||||||
import { EditShortUrlData, ShortUrl } from '../data';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
const REDUCER_PREFIX = 'shlink/shortUrlEdition';
|
||||||
export const EDIT_SHORT_URL_ERROR = 'shlink/shortUrlEdition/EDIT_SHORT_URL_ERROR';
|
|
||||||
export const SHORT_URL_EDITED = 'shlink/shortUrlEdition/SHORT_URL_EDITED';
|
|
||||||
|
|
||||||
export interface ShortUrlEdition {
|
export interface ShortUrlEdition {
|
||||||
shortUrl?: ShortUrl;
|
shortUrl?: ShortUrl;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
saved: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlEditedAction extends Action<string> {
|
export interface EditShortUrl extends ShortUrlIdentifier {
|
||||||
shortUrl: ShortUrl;
|
data: EditShortUrlData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ShortUrlEditedAction = PayloadAction<ShortUrl>;
|
||||||
|
|
||||||
const initialState: ShortUrlEdition = {
|
const initialState: ShortUrlEdition = {
|
||||||
saving: false,
|
saving: false,
|
||||||
|
saved: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
`${REDUCER_PREFIX}/editShortUrl`,
|
||||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
({ shortCode, domain, data }: EditShortUrl, { getState }): Promise<ShortUrl> => {
|
||||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
const { updateShortUrl } = buildShlinkApiClient(getState);
|
||||||
}, initialState);
|
return updateShortUrl(shortCode, domain, data as any); // FIXME parse dates
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const shortUrlEditionReducerCreator = (editShortUrlThunk: ReturnType<typeof editShortUrl>) => createSlice({
|
||||||
shortCode: string,
|
name: REDUCER_PREFIX,
|
||||||
domain: OptionalString,
|
initialState,
|
||||||
data: EditShortUrlData,
|
reducers: {},
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
extraReducers: (builder) => {
|
||||||
dispatch({ type: EDIT_SHORT_URL_START });
|
builder.addCase(editShortUrlThunk.pending, (state) => ({ ...state, saving: true, error: false, saved: false }));
|
||||||
|
builder.addCase(
|
||||||
const { updateShortUrl } = buildShlinkApiClient(getState);
|
editShortUrlThunk.rejected,
|
||||||
|
(state, { error }) => ({ ...state, saving: false, error: true, saved: false, errorData: parseApiError(error) }),
|
||||||
try {
|
);
|
||||||
const shortUrl = await updateShortUrl(shortCode, domain, data as any); // FIXME parse dates;
|
builder.addCase(
|
||||||
|
editShortUrlThunk.fulfilled,
|
||||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
(_, { payload: shortUrl }) => ({ shortUrl, saving: false, error: false, saved: true }),
|
||||||
} catch (e: any) {
|
);
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
},
|
||||||
|
});
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
|
import { createSlice } from '@reduxjs/toolkit';
|
||||||
import { assoc, assocPath, last, pipe, reject } from 'ramda';
|
import { assoc, assocPath, last, pipe, reject } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import { shortUrlMatches } from '../helpers';
|
import { shortUrlMatches } from '../helpers';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { shortUrlDeleted } from './shortUrlDeletion';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { createShortUrl } from './shortUrlCreation';
|
||||||
import { SHORT_URL_EDITED, ShortUrlEditedAction } from './shortUrlEdition';
|
import { editShortUrl } from './shortUrlEdition';
|
||||||
|
import { ShortUrl } from '../data';
|
||||||
export const LIST_SHORT_URLS_START = 'shlink/shortUrlsList/LIST_SHORT_URLS_START';
|
|
||||||
export const LIST_SHORT_URLS_ERROR = 'shlink/shortUrlsList/LIST_SHORT_URLS_ERROR';
|
|
||||||
export const LIST_SHORT_URLS = 'shlink/shortUrlsList/LIST_SHORT_URLS';
|
|
||||||
|
|
||||||
|
const REDUCER_PREFIX = 'shlink/shortUrlsList';
|
||||||
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
export const ITEMS_IN_OVERVIEW_PAGE = 5;
|
||||||
|
|
||||||
export interface ShortUrlsList {
|
export interface ShortUrlsList {
|
||||||
|
@ -22,94 +19,103 @@ export interface ShortUrlsList {
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListShortUrlsAction extends Action<string> {
|
|
||||||
shortUrls: ShlinkShortUrlsResponse;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ListShortUrlsCombinedAction = (
|
|
||||||
ListShortUrlsAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& CreateShortUrlAction
|
|
||||||
& DeleteShortUrlAction
|
|
||||||
& ShortUrlEditedAction
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialState: ShortUrlsList = {
|
const initialState: ShortUrlsList = {
|
||||||
loading: true,
|
loading: true,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
|
||||||
[LIST_SHORT_URLS_START]: (state) => ({ ...state, loading: true, error: false }),
|
`${REDUCER_PREFIX}/listShortUrls`,
|
||||||
[LIST_SHORT_URLS_ERROR]: () => ({ loading: false, error: true }),
|
(params: ShlinkShortUrlsListParams | void, { getState }): Promise<ShlinkShortUrlsResponse> => {
|
||||||
[LIST_SHORT_URLS]: (_, { shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
|
||||||
[SHORT_URL_DELETED]: pipe(
|
return shlinkListShortUrls(params ?? {});
|
||||||
(state: ShortUrlsList, { shortCode, domain }: DeleteShortUrlAction) => (!state.shortUrls ? state : assocPath(
|
},
|
||||||
['shortUrls', 'data'],
|
);
|
||||||
reject((shortUrl) => shortUrlMatches(shortUrl, shortCode, domain), state.shortUrls.data),
|
|
||||||
state,
|
|
||||||
)),
|
|
||||||
(state) => (!state.shortUrls ? state : assocPath(
|
|
||||||
['shortUrls', 'pagination', 'totalItems'],
|
|
||||||
state.shortUrls.pagination.totalItems - 1,
|
|
||||||
state,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => assocPath(
|
|
||||||
['shortUrls', 'data'],
|
|
||||||
state.shortUrls?.data?.map(
|
|
||||||
(currentShortUrl) => {
|
|
||||||
// Find the last of the new visit for this short URL, and pick the amount of visits from it
|
|
||||||
const lastVisit = last(
|
|
||||||
createdVisits.filter(
|
|
||||||
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return lastVisit?.shortUrl
|
export const shortUrlsListReducerCreator = (
|
||||||
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
|
listShortUrlsThunk: ReturnType<typeof listShortUrls>,
|
||||||
: currentShortUrl;
|
editShortUrlThunk: ReturnType<typeof editShortUrl>,
|
||||||
},
|
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||||
),
|
) => createSlice({
|
||||||
state,
|
name: REDUCER_PREFIX,
|
||||||
),
|
initialState,
|
||||||
[CREATE_SHORT_URL]: pipe(
|
reducers: {},
|
||||||
// The only place where the list and the creation form coexist is the overview page.
|
extraReducers: (builder) => {
|
||||||
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
builder.addCase(listShortUrlsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||||
// We can also remove the items above the amount that is displayed there.
|
builder.addCase(listShortUrlsThunk.rejected, () => ({ loading: false, error: true }));
|
||||||
(state: ShortUrlsList, { result }: CreateShortUrlAction) => (!state.shortUrls ? state : assocPath(
|
builder.addCase(
|
||||||
['shortUrls', 'data'],
|
listShortUrlsThunk.fulfilled,
|
||||||
[result, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
|
(_, { payload: shortUrls }) => ({ loading: false, error: false, shortUrls }),
|
||||||
state,
|
);
|
||||||
)),
|
|
||||||
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
|
|
||||||
['shortUrls', 'pagination', 'totalItems'],
|
|
||||||
state.shortUrls.pagination.totalItems + 1,
|
|
||||||
state,
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
[SHORT_URL_EDITED]: (state, { shortUrl: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
|
|
||||||
['shortUrls', 'data'],
|
|
||||||
state.shortUrls.data.map((shortUrl) => {
|
|
||||||
const { shortCode, domain } = editedShortUrl;
|
|
||||||
|
|
||||||
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
builder.addCase(
|
||||||
}),
|
createShortUrlThunk.fulfilled,
|
||||||
state,
|
pipe(
|
||||||
)),
|
// The only place where the list and the creation form coexist is the overview page.
|
||||||
}, initialState);
|
// There we can assume we are displaying page 1, and therefore, we can safely prepend the new short URL.
|
||||||
|
// We can also remove the items above the amount that is displayed there.
|
||||||
|
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||||
|
['shortUrls', 'data'],
|
||||||
|
[payload, ...state.shortUrls.data.slice(0, ITEMS_IN_OVERVIEW_PAGE - 1)],
|
||||||
|
state,
|
||||||
|
)),
|
||||||
|
(state: ShortUrlsList) => (!state.shortUrls ? state : assocPath(
|
||||||
|
['shortUrls', 'pagination', 'totalItems'],
|
||||||
|
state.shortUrls.pagination.totalItems + 1,
|
||||||
|
state,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
builder.addCase(
|
||||||
params: ShlinkShortUrlsListParams = {},
|
editShortUrlThunk.fulfilled,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
(state, { payload: editedShortUrl }) => (!state.shortUrls ? state : assocPath(
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
['shortUrls', 'data'],
|
||||||
const { listShortUrls: shlinkListShortUrls } = buildShlinkApiClient(getState);
|
state.shortUrls.data.map((shortUrl) => {
|
||||||
|
const { shortCode, domain } = editedShortUrl;
|
||||||
|
return shortUrlMatches(shortUrl, shortCode, domain) ? editedShortUrl : shortUrl;
|
||||||
|
}),
|
||||||
|
state,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
builder.addCase(
|
||||||
const shortUrls = await shlinkListShortUrls(params);
|
shortUrlDeleted,
|
||||||
|
pipe(
|
||||||
|
(state, { payload }) => (!state.shortUrls ? state : assocPath(
|
||||||
|
['shortUrls', 'data'],
|
||||||
|
reject<ShortUrl, ShortUrl[]>((shortUrl) =>
|
||||||
|
shortUrlMatches(shortUrl, payload.shortCode, payload.domain), state.shortUrls.data),
|
||||||
|
state,
|
||||||
|
)),
|
||||||
|
(state) => (!state.shortUrls ? state : assocPath(
|
||||||
|
['shortUrls', 'pagination', 'totalItems'],
|
||||||
|
state.shortUrls.pagination.totalItems - 1,
|
||||||
|
state,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
dispatch<ListShortUrlsAction>({ type: LIST_SHORT_URLS, shortUrls });
|
builder.addCase(
|
||||||
} catch (e) {
|
createNewVisits,
|
||||||
dispatch({ type: LIST_SHORT_URLS_ERROR });
|
(state, { payload }) => assocPath(
|
||||||
}
|
['shortUrls', 'data'],
|
||||||
};
|
state.shortUrls?.data?.map(
|
||||||
|
(currentShortUrl) => {
|
||||||
|
// Find the last of the new visit for this short URL, and pick the amount of visits from it
|
||||||
|
const lastVisit = last(
|
||||||
|
payload.createdVisits.filter(
|
||||||
|
({ shortUrl }) => shortUrl && shortUrlMatches(currentShortUrl, shortUrl.shortCode, shortUrl.domain),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return lastVisit?.shortUrl
|
||||||
|
? assoc('visitsCount', lastVisit.shortUrl.visitsCount, currentShortUrl)
|
||||||
|
: currentShortUrl;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle from 'bottlejs';
|
||||||
|
import { prop } from 'ramda';
|
||||||
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
|
import { ShortUrlsFilteringBar } from '../ShortUrlsFilteringBar';
|
||||||
import { ShortUrlsList } from '../ShortUrlsList';
|
import { ShortUrlsList } from '../ShortUrlsList';
|
||||||
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
|
import { ShortUrlsRow } from '../helpers/ShortUrlsRow';
|
||||||
|
@ -6,16 +7,16 @@ import { ShortUrlsRowMenu } from '../helpers/ShortUrlsRowMenu';
|
||||||
import { CreateShortUrl } from '../CreateShortUrl';
|
import { CreateShortUrl } from '../CreateShortUrl';
|
||||||
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
|
import { DeleteShortUrlModal } from '../helpers/DeleteShortUrlModal';
|
||||||
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
|
import { CreateShortUrlResult } from '../helpers/CreateShortUrlResult';
|
||||||
import { listShortUrls } from '../reducers/shortUrlsList';
|
import { listShortUrls, shortUrlsListReducerCreator } from '../reducers/shortUrlsList';
|
||||||
import { createShortUrl, resetCreateShortUrl } from '../reducers/shortUrlCreation';
|
import { shortUrlCreationReducerCreator, createShortUrl } from '../reducers/shortUrlCreation';
|
||||||
import { deleteShortUrl, resetDeleteShortUrl } from '../reducers/shortUrlDeletion';
|
import { shortUrlDeletionReducerCreator, deleteShortUrl, shortUrlDeleted } from '../reducers/shortUrlDeletion';
|
||||||
import { editShortUrl } from '../reducers/shortUrlEdition';
|
import { editShortUrl, shortUrlEditionReducerCreator } from '../reducers/shortUrlEdition';
|
||||||
|
import { shortUrlDetailReducerCreator } from '../reducers/shortUrlDetail';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { ShortUrlsTable } from '../ShortUrlsTable';
|
import { ShortUrlsTable } from '../ShortUrlsTable';
|
||||||
import { QrCodeModal } from '../helpers/QrCodeModal';
|
|
||||||
import { ShortUrlForm } from '../ShortUrlForm';
|
import { ShortUrlForm } from '../ShortUrlForm';
|
||||||
import { EditShortUrl } from '../EditShortUrl';
|
import { EditShortUrl } from '../EditShortUrl';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { QrCodeModal } from '../helpers/QrCodeModal';
|
||||||
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
|
import { ExportShortUrlsBtn } from '../helpers/ExportShortUrlsBtn';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
@ -35,7 +36,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
|
bottle.serviceFactory('CreateShortUrl', CreateShortUrl, 'ShortUrlForm', 'CreateShortUrlResult');
|
||||||
bottle.decorator(
|
bottle.decorator(
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
connect(['shortUrlCreationResult', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
connect(['shortUrlCreation', 'selectedServer', 'settings'], ['createShortUrl', 'resetCreateShortUrl']),
|
||||||
);
|
);
|
||||||
|
|
||||||
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
|
bottle.serviceFactory('EditShortUrl', EditShortUrl, 'ShortUrlForm');
|
||||||
|
@ -45,7 +46,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
));
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||||
bottle.decorator('DeleteShortUrlModal', connect(['shortUrlDeletion'], ['deleteShortUrl', 'resetDeleteShortUrl']));
|
bottle.decorator('DeleteShortUrlModal', connect(
|
||||||
|
['shortUrlDeletion'],
|
||||||
|
['deleteShortUrl', 'shortUrlDeleted', 'resetDeleteShortUrl'],
|
||||||
|
));
|
||||||
|
|
||||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
|
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader');
|
||||||
bottle.decorator('QrCodeModal', connect(['selectedServer']));
|
bottle.decorator('QrCodeModal', connect(['selectedServer']));
|
||||||
|
@ -55,16 +59,39 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
|
bottle.serviceFactory('ExportShortUrlsBtn', ExportShortUrlsBtn, 'buildShlinkApiClient', 'ReportExporter');
|
||||||
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
|
bottle.decorator('ExportShortUrlsBtn', connect(['selectedServer']));
|
||||||
|
|
||||||
|
// Reducers
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'shortUrlsListReducerCreator',
|
||||||
|
shortUrlsListReducerCreator,
|
||||||
|
'listShortUrls',
|
||||||
|
'editShortUrl',
|
||||||
|
'createShortUrl',
|
||||||
|
);
|
||||||
|
bottle.serviceFactory('shortUrlsListReducer', prop('reducer'), 'shortUrlsListReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('shortUrlCreationReducerCreator', shortUrlCreationReducerCreator, 'createShortUrl');
|
||||||
|
bottle.serviceFactory('shortUrlCreationReducer', prop('reducer'), 'shortUrlCreationReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('shortUrlEditionReducerCreator', shortUrlEditionReducerCreator, 'editShortUrl');
|
||||||
|
bottle.serviceFactory('shortUrlEditionReducer', prop('reducer'), 'shortUrlEditionReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('shortUrlDeletionReducerCreator', shortUrlDeletionReducerCreator, 'deleteShortUrl');
|
||||||
|
bottle.serviceFactory('shortUrlDeletionReducer', prop('reducer'), 'shortUrlDeletionReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('shortUrlDetailReducerCreator', shortUrlDetailReducerCreator, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('shortUrlDetailReducer', prop('reducer'), 'shortUrlDetailReducerCreator');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
|
||||||
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('createShortUrl', createShortUrl, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetCreateShortUrl', () => resetCreateShortUrl);
|
bottle.serviceFactory('resetCreateShortUrl', prop('resetCreateShortUrl'), 'shortUrlCreationReducerCreator');
|
||||||
|
|
||||||
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('deleteShortUrl', deleteShortUrl, 'buildShlinkApiClient');
|
||||||
bottle.serviceFactory('resetDeleteShortUrl', () => resetDeleteShortUrl);
|
bottle.serviceFactory('resetDeleteShortUrl', prop('resetDeleteShortUrl'), 'shortUrlDeletionReducerCreator');
|
||||||
|
bottle.serviceFactory('shortUrlDeleted', () => shortUrlDeleted);
|
||||||
|
|
||||||
bottle.serviceFactory('getShortUrlDetail', getShortUrlDetail, 'buildShlinkApiClient');
|
bottle.serviceFactory('getShortUrlDetail', prop('getShortUrlDetail'), 'shortUrlDetailReducerCreator');
|
||||||
|
|
||||||
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
|
bottle.serviceFactory('editShortUrl', editShortUrl, 'buildShlinkApiClient');
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,15 +13,14 @@ interface DeleteTagConfirmModalProps extends TagModalProps {
|
||||||
export const DeleteTagConfirmModal = (
|
export const DeleteTagConfirmModal = (
|
||||||
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
{ tag, toggle, isOpen, deleteTag, tagDelete, tagDeleted }: DeleteTagConfirmModalProps,
|
||||||
) => {
|
) => {
|
||||||
const { deleting, error, errorData } = tagDelete;
|
const { deleting, error, deleted, errorData } = tagDelete;
|
||||||
const doDelete = async () => {
|
const doDelete = async () => {
|
||||||
await deleteTag(tag);
|
await deleteTag(tag);
|
||||||
tagDeleted(tag);
|
|
||||||
toggle();
|
toggle();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal toggle={toggle} isOpen={isOpen} centered>
|
<Modal toggle={toggle} isOpen={isOpen} centered onClosed={() => deleted && tagDeleted(tag)}>
|
||||||
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
|
<ModalHeader toggle={toggle} className="text-danger">Delete tag</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
Are you sure you want to delete tag <b>{tag}</b>?
|
Are you sure you want to delete tag <b>{tag}</b>?
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { pipe } from 'ramda';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover, InputGroup } from 'reactstrap';
|
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover, InputGroup } from 'reactstrap';
|
||||||
import { HexColorPicker } from 'react-colorful';
|
import { HexColorPicker } from 'react-colorful';
|
||||||
|
@ -7,15 +8,15 @@ import { useToggle } from '../../utils/helpers/hooks';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||||
import { TagModalProps } from '../data';
|
import { TagModalProps } from '../data';
|
||||||
import { TagEdition } from '../reducers/tagEdit';
|
import { EditTag, TagEdition } from '../reducers/tagEdit';
|
||||||
import { Result } from '../../utils/Result';
|
import { Result } from '../../utils/Result';
|
||||||
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
import { ShlinkApiError } from '../../api/ShlinkApiError';
|
||||||
import './EditTagModal.scss';
|
import './EditTagModal.scss';
|
||||||
|
|
||||||
interface EditTagModalProps extends TagModalProps {
|
interface EditTagModalProps extends TagModalProps {
|
||||||
tagEdit: TagEdition;
|
tagEdit: TagEdition;
|
||||||
editTag: (oldName: string, newName: string, color: string) => Promise<void>;
|
editTag: (editTag: EditTag) => Promise<void>;
|
||||||
tagEdited: (oldName: string, newName: string, color: string) => void;
|
tagEdited: (tagEdited: EditTag) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||||
|
@ -24,16 +25,17 @@ export const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
||||||
const [newTagName, setNewTagName] = useState(tag);
|
const [newTagName, setNewTagName] = useState(tag);
|
||||||
const [color, setColor] = useState(getColorForKey(tag));
|
const [color, setColor] = useState(getColorForKey(tag));
|
||||||
const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
|
const [showColorPicker, toggleColorPicker, , hideColorPicker] = useToggle();
|
||||||
const { editing, error, errorData } = tagEdit;
|
const { editing, error, edited, errorData } = tagEdit;
|
||||||
const saveTag = handleEventPreventingDefault(
|
const saveTag = handleEventPreventingDefault(
|
||||||
async () => editTag(tag, newTagName, color)
|
async () => {
|
||||||
.then(() => tagEdited(tag, newTagName, color))
|
await editTag({ oldName: tag, newName: newTagName, color });
|
||||||
.then(toggle)
|
toggle();
|
||||||
.catch(() => {}),
|
},
|
||||||
);
|
);
|
||||||
|
const onClosed = pipe(hideColorPicker, () => edited && tagEdited({ oldName: tag, newName: newTagName, color }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
||||||
<form name="editTag" onSubmit={saveTag}>
|
<form name="editTag" onSubmit={saveTag}>
|
||||||
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
<ModalHeader toggle={toggle}>Edit tag</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
|
|
|
@ -1,52 +1,45 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
const REDUCER_PREFIX = 'shlink/tagDelete';
|
||||||
export const DELETE_TAG_ERROR = 'shlink/deleteTag/DELETE_TAG_ERROR';
|
|
||||||
export const DELETE_TAG = 'shlink/deleteTag/DELETE_TAG';
|
|
||||||
export const TAG_DELETED = 'shlink/deleteTag/TAG_DELETED';
|
|
||||||
|
|
||||||
export interface TagDeletion {
|
export interface TagDeletion {
|
||||||
deleting: boolean;
|
deleting: boolean;
|
||||||
|
deleted: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeleteTagAction extends Action<string> {
|
|
||||||
tag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TagDeletion = {
|
const initialState: TagDeletion = {
|
||||||
deleting: false,
|
deleting: false,
|
||||||
|
deleted: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagDeletion, ApiErrorAction>({
|
export const tagDeleted = createAction<string>(`${REDUCER_PREFIX}/tagDeleted`);
|
||||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
|
||||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
|
||||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string) => async (
|
export const tagDeleteReducerCreator = (buildShlinkApiClient: ShlinkApiClientBuilder) => {
|
||||||
dispatch: Dispatch,
|
const deleteTag = createAsyncThunk(`${REDUCER_PREFIX}/deleteTag`, async (tag: string, { getState }): Promise<void> => {
|
||||||
getState: GetState,
|
const { deleteTags } = buildShlinkApiClient(getState);
|
||||||
) => {
|
|
||||||
dispatch({ type: DELETE_TAG_START });
|
|
||||||
const { deleteTags } = buildShlinkApiClient(getState);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deleteTags([tag]);
|
await deleteTags([tag]);
|
||||||
dispatch({ type: DELETE_TAG });
|
});
|
||||||
} catch (e: any) {
|
|
||||||
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
|
||||||
|
|
||||||
throw e;
|
const { reducer } = createSlice({
|
||||||
}
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(deleteTag.pending, () => ({ deleting: true, deleted: false, error: false }));
|
||||||
|
builder.addCase(
|
||||||
|
deleteTag.rejected,
|
||||||
|
(_, { error }) => ({ deleting: false, deleted: false, error: true, errorData: parseApiError(error) }),
|
||||||
|
);
|
||||||
|
builder.addCase(deleteTag.fulfilled, () => ({ deleting: false, deleted: true, error: false }));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { reducer, deleteTag };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tagDeleted = (tag: string): DeleteTagAction => ({ type: TAG_DELETED, tag });
|
|
||||||
|
|
|
@ -1,72 +1,66 @@
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createAction, createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
import { ColorGenerator } from '../../utils/services/ColorGenerator';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
const REDUCER_PREFIX = 'shlink/tagEdit';
|
||||||
export const EDIT_TAG_ERROR = 'shlink/editTag/EDIT_TAG_ERROR';
|
|
||||||
export const EDIT_TAG = 'shlink/editTag/EDIT_TAG';
|
|
||||||
|
|
||||||
export const TAG_EDITED = 'shlink/editTag/TAG_EDITED';
|
|
||||||
|
|
||||||
export interface TagEdition {
|
export interface TagEdition {
|
||||||
oldName: string;
|
oldName?: string;
|
||||||
newName: string;
|
newName?: string;
|
||||||
editing: boolean;
|
editing: boolean;
|
||||||
|
edited: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditTagAction extends Action<string> {
|
export interface EditTag {
|
||||||
oldName: string;
|
oldName: string;
|
||||||
newName: string;
|
newName: string;
|
||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EditTagAction = PayloadAction<EditTag>;
|
||||||
|
|
||||||
const initialState: TagEdition = {
|
const initialState: TagEdition = {
|
||||||
oldName: '',
|
|
||||||
newName: '',
|
|
||||||
editing: false,
|
editing: false,
|
||||||
|
edited: false,
|
||||||
error: false,
|
error: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
export const tagEdited = createAction<EditTag>(`${REDUCER_PREFIX}/tagEdited`);
|
||||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
|
||||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
|
||||||
[EDIT_TAG]: (_, action) => ({
|
|
||||||
...pick(['oldName', 'newName'], action),
|
|
||||||
editing: false,
|
|
||||||
error: false,
|
|
||||||
}),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGenerator: ColorGenerator) => (
|
export const editTag = (
|
||||||
oldName: string,
|
buildShlinkApiClient: ShlinkApiClientBuilder,
|
||||||
newName: string,
|
colorGenerator: ColorGenerator,
|
||||||
color: string,
|
) => createAsyncThunk(
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
`${REDUCER_PREFIX}/editTag`,
|
||||||
dispatch({ type: EDIT_TAG_START });
|
async ({ oldName, newName, color }: EditTag, { getState }): Promise<EditTag> => {
|
||||||
const { editTag: shlinkEditTag } = buildShlinkApiClient(getState);
|
await buildShlinkApiClient(getState).editTag(oldName, newName);
|
||||||
|
|
||||||
try {
|
|
||||||
await shlinkEditTag(oldName, newName);
|
|
||||||
colorGenerator.setColorForKey(newName, color);
|
colorGenerator.setColorForKey(newName, color);
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
|
||||||
} catch (e: any) {
|
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
|
||||||
|
|
||||||
throw e;
|
return { oldName, newName, color };
|
||||||
}
|
},
|
||||||
};
|
);
|
||||||
|
|
||||||
export const tagEdited = (oldName: string, newName: string, color: string): EditTagAction => ({
|
export const tagEditReducerCreator = (editTagThunk: ReturnType<typeof editTag>) => createSlice({
|
||||||
type: TAG_EDITED,
|
name: REDUCER_PREFIX,
|
||||||
oldName,
|
initialState,
|
||||||
newName,
|
reducers: {},
|
||||||
color,
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(editTagThunk.pending, () => ({ editing: true, edited: false, error: false }));
|
||||||
|
builder.addCase(
|
||||||
|
editTagThunk.rejected,
|
||||||
|
(_, { error }) => ({ editing: false, edited: false, error: true, errorData: parseApiError(error) }),
|
||||||
|
);
|
||||||
|
builder.addCase(editTagThunk.fulfilled, (_, { payload }) => ({
|
||||||
|
...pick(['oldName', 'newName'], payload),
|
||||||
|
editing: false,
|
||||||
|
edited: true,
|
||||||
|
error: false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,22 +1,18 @@
|
||||||
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { isEmpty, reject } from 'ramda';
|
import { isEmpty, reject } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createNewVisits } from '../../visits/reducers/visitCreation';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCreation';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { ShlinkTags } from '../../api/types';
|
||||||
import { ProblemDetailsError, ShlinkTags } from '../../api/types';
|
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { CreateVisit, Stats } from '../../visits/types';
|
import { CreateVisit, Stats } from '../../visits/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { TagStats } from '../data';
|
import { TagStats } from '../data';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { createShortUrl } from '../../short-urls/reducers/shortUrlCreation';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from '../../short-urls/reducers/shortUrlCreation';
|
import { tagDeleted } from './tagDelete';
|
||||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
import { tagEdited } from './tagEdit';
|
||||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
import { ProblemDetailsError } from '../../api/types/errors';
|
||||||
|
|
||||||
export const LIST_TAGS_START = 'shlink/tagsList/LIST_TAGS_START';
|
const REDUCER_PREFIX = 'shlink/tagsList';
|
||||||
export const LIST_TAGS_ERROR = 'shlink/tagsList/LIST_TAGS_ERROR';
|
|
||||||
export const LIST_TAGS = 'shlink/tagsList/LIST_TAGS';
|
|
||||||
export const FILTER_TAGS = 'shlink/tagsList/FILTER_TAGS';
|
|
||||||
|
|
||||||
type TagsStatsMap = Record<string, TagStats>;
|
type TagsStatsMap = Record<string, TagStats>;
|
||||||
|
|
||||||
|
@ -29,24 +25,12 @@ export interface TagsList {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListTagsAction extends Action<string> {
|
interface ListTags {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
stats: TagsStatsMap;
|
stats: TagsStatsMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FilterTagsAction extends Action<string> {
|
const initialState: TagsList = {
|
||||||
searchTerm: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TagsCombinedAction = ListTagsAction
|
|
||||||
& DeleteTagAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& CreateShortUrlAction
|
|
||||||
& EditTagAction
|
|
||||||
& FilterTagsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState = {
|
|
||||||
tags: [],
|
tags: [],
|
||||||
filteredTags: [],
|
filteredTags: [],
|
||||||
stats: {},
|
stats: {},
|
||||||
|
@ -65,10 +49,13 @@ const increaseVisitsForTags = (tags: TagIncrease[], stats: TagsStatsMap) => tags
|
||||||
|
|
||||||
const tagStats = theStats[tag];
|
const tagStats = theStats[tag];
|
||||||
|
|
||||||
tagStats.visitsCount += increase;
|
return {
|
||||||
theStats[tag] = tagStats; // eslint-disable-line no-param-reassign
|
...theStats,
|
||||||
|
[tag]: {
|
||||||
return theStats;
|
...tagStats,
|
||||||
|
visitsCount: tagStats.visitsCount + increase,
|
||||||
|
},
|
||||||
|
};
|
||||||
}, { ...stats });
|
}, { ...stats });
|
||||||
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => Object.entries(
|
||||||
createdVisits.reduce<Stats>((acc, { shortUrl }) => {
|
createdVisits.reduce<Stats>((acc, { shortUrl }) => {
|
||||||
|
@ -80,47 +67,15 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default buildReducer<TagsList, TagsCombinedAction>({
|
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => createAsyncThunk(
|
||||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
`${REDUCER_PREFIX}/listTags`,
|
||||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
async (_: void, { getState }): Promise<ListTags> => {
|
||||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
const { tagsList } = getState();
|
||||||
[TAG_DELETED]: (state, { tag }) => ({
|
|
||||||
...state,
|
|
||||||
tags: rejectTag(state.tags, tag),
|
|
||||||
filteredTags: rejectTag(state.filteredTags, tag),
|
|
||||||
}),
|
|
||||||
[TAG_EDITED]: (state, { oldName, newName }) => ({
|
|
||||||
...state,
|
|
||||||
tags: state.tags.map(renameTag(oldName, newName)).sort(),
|
|
||||||
filteredTags: state.filteredTags.map(renameTag(oldName, newName)).sort(),
|
|
||||||
}),
|
|
||||||
[FILTER_TAGS]: (state, { searchTerm }) => ({
|
|
||||||
...state,
|
|
||||||
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
|
|
||||||
}),
|
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => ({
|
|
||||||
...state,
|
|
||||||
stats: increaseVisitsForTags(calculateVisitsPerTag(createdVisits), state.stats),
|
|
||||||
}),
|
|
||||||
[CREATE_SHORT_URL]: ({ tags: stateTags, ...rest }, { result }) => ({
|
|
||||||
...rest,
|
|
||||||
tags: stateTags.concat(result.tags.filter((tag) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
|
||||||
}),
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = true) => () => async (
|
if (!force && !isEmpty(tagsList.tags)) {
|
||||||
dispatch: Dispatch,
|
return tagsList;
|
||||||
getState: GetState,
|
}
|
||||||
) => {
|
|
||||||
const { tagsList } = getState();
|
|
||||||
|
|
||||||
if (!force && (tagsList.loading || !isEmpty(tagsList.tags))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({ type: LIST_TAGS_START });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { listTags: shlinkListTags } = buildShlinkApiClient(getState);
|
const { listTags: shlinkListTags } = buildShlinkApiClient(getState);
|
||||||
const { tags, stats = [] }: ShlinkTags = await shlinkListTags();
|
const { tags, stats = [] }: ShlinkTags = await shlinkListTags();
|
||||||
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
const processedStats = stats.reduce<TagsStatsMap>((acc, { tag, shortUrlsCount, visitsCount }) => {
|
||||||
|
@ -129,10 +84,55 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
return { tags, stats: processedStats };
|
||||||
} catch (e: any) {
|
},
|
||||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterTags = (searchTerm: string): FilterTagsAction => ({ type: FILTER_TAGS, searchTerm });
|
export const filterTags = createAction<string>(`${REDUCER_PREFIX}/filterTags`);
|
||||||
|
|
||||||
|
export const tagsListReducerCreator = (
|
||||||
|
listTagsThunk: ReturnType<typeof listTags>,
|
||||||
|
createShortUrlThunk: ReturnType<typeof createShortUrl>,
|
||||||
|
) => createSlice({
|
||||||
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
reducers: {},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(filterTags, (state, { payload: searchTerm }) => ({
|
||||||
|
...state,
|
||||||
|
filteredTags: state.tags.filter((tag) => tag.toLowerCase().match(searchTerm.toLowerCase())),
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.addCase(listTagsThunk.pending, (state) => ({ ...state, loading: true, error: false }));
|
||||||
|
builder.addCase(listTagsThunk.rejected, (_, { error }) => (
|
||||||
|
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||||
|
));
|
||||||
|
builder.addCase(listTagsThunk.fulfilled, (_, { payload }) => (
|
||||||
|
{ ...initialState, stats: payload.stats, tags: payload.tags, filteredTags: payload.tags }
|
||||||
|
));
|
||||||
|
|
||||||
|
builder.addCase(tagDeleted, ({ tags, filteredTags, ...rest }, { payload: tag }) => ({
|
||||||
|
...rest,
|
||||||
|
tags: rejectTag(tags, tag),
|
||||||
|
filteredTags: rejectTag(filteredTags, tag),
|
||||||
|
}));
|
||||||
|
builder.addCase(tagEdited, ({ tags, filteredTags, stats, ...rest }, { payload }) => ({
|
||||||
|
...rest,
|
||||||
|
stats: {
|
||||||
|
...stats,
|
||||||
|
[payload.newName]: stats[payload.oldName],
|
||||||
|
},
|
||||||
|
tags: tags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||||
|
filteredTags: filteredTags.map(renameTag(payload.oldName, payload.newName)).sort(),
|
||||||
|
}));
|
||||||
|
builder.addCase(createNewVisits, (state, { payload }) => ({
|
||||||
|
...state,
|
||||||
|
stats: increaseVisitsForTags(calculateVisitsPerTag(payload.createdVisits), state.stats),
|
||||||
|
}));
|
||||||
|
|
||||||
|
builder.addCase(createShortUrlThunk.fulfilled, ({ tags: stateTags, ...rest }, { payload }) => ({
|
||||||
|
...rest,
|
||||||
|
tags: stateTags.concat(payload.tags.filter((tag: string) => !stateTags.includes(tag))), // More performant than [ ...new Set(...) ]
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
|
import { prop } from 'ramda';
|
||||||
import Bottle, { IContainer } from 'bottlejs';
|
import Bottle, { IContainer } from 'bottlejs';
|
||||||
import { TagsSelector } from '../helpers/TagsSelector';
|
import { TagsSelector } from '../helpers/TagsSelector';
|
||||||
import { TagCard } from '../TagCard';
|
import { TagCard } from '../TagCard';
|
||||||
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
|
import { DeleteTagConfirmModal } from '../helpers/DeleteTagConfirmModal';
|
||||||
import { EditTagModal } from '../helpers/EditTagModal';
|
import { EditTagModal } from '../helpers/EditTagModal';
|
||||||
import { TagsList } from '../TagsList';
|
import { TagsList } from '../TagsList';
|
||||||
import { filterTags, listTags } from '../reducers/tagsList';
|
import { filterTags, listTags, tagsListReducerCreator } from '../reducers/tagsList';
|
||||||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
import { tagDeleted, tagDeleteReducerCreator } from '../reducers/tagDelete';
|
||||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
import { editTag, tagEdited, tagEditReducerCreator } from '../reducers/tagEdit';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { TagsCards } from '../TagsCards';
|
import { TagsCards } from '../TagsCards';
|
||||||
import { TagsTable } from '../TagsTable';
|
import { TagsTable } from '../TagsTable';
|
||||||
|
@ -36,6 +37,16 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],
|
['forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo'],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Reducers
|
||||||
|
bottle.serviceFactory('tagEditReducerCreator', tagEditReducerCreator, 'editTag');
|
||||||
|
bottle.serviceFactory('tagEditReducer', prop('reducer'), 'tagEditReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('tagDeleteReducerCreator', tagDeleteReducerCreator, 'buildShlinkApiClient');
|
||||||
|
bottle.serviceFactory('tagDeleteReducer', prop('reducer'), 'tagDeleteReducerCreator');
|
||||||
|
|
||||||
|
bottle.serviceFactory('tagsListReducerCreator', tagsListReducerCreator, 'listTags', 'createShortUrl');
|
||||||
|
bottle.serviceFactory('tagsListReducer', prop('reducer'), 'tagsListReducerCreator');
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const listTagsActionFactory = (force: boolean) =>
|
const listTagsActionFactory = (force: boolean) =>
|
||||||
({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force);
|
({ buildShlinkApiClient }: IContainer) => listTags(buildShlinkApiClient, force);
|
||||||
|
@ -43,11 +54,12 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.factory('listTags', listTagsActionFactory(false));
|
bottle.factory('listTags', listTagsActionFactory(false));
|
||||||
bottle.factory('forceListTags', listTagsActionFactory(true));
|
bottle.factory('forceListTags', listTagsActionFactory(true));
|
||||||
bottle.serviceFactory('filterTags', () => filterTags);
|
bottle.serviceFactory('filterTags', () => filterTags);
|
||||||
bottle.serviceFactory('tagDeleted', () => tagDeleted);
|
|
||||||
bottle.serviceFactory('tagEdited', () => tagEdited);
|
|
||||||
|
|
||||||
bottle.serviceFactory('deleteTag', deleteTag, 'buildShlinkApiClient');
|
bottle.serviceFactory('deleteTag', prop('deleteTag'), 'tagDeleteReducerCreator');
|
||||||
|
bottle.serviceFactory('tagDeleted', () => tagDeleted);
|
||||||
|
|
||||||
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
|
bottle.serviceFactory('editTag', editTag, 'buildShlinkApiClient', 'ColorGenerator');
|
||||||
|
bottle.serviceFactory('tagEdited', () => tagEdited);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default provideServices;
|
export default provideServices;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
@import './mixins/vertical-align';
|
@import '../mixins/vertical-align';
|
||||||
@import './base';
|
@import '../base';
|
||||||
|
|
||||||
.date-input-container {
|
.date-input-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -51,8 +51,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time.react-datepicker__time,
|
||||||
.react-datepicker.react-datepicker {
|
.react-datepicker.react-datepicker {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color) !important;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
border-color: var(--border-color);
|
border-color: var(--border-color);
|
||||||
}
|
}
|
||||||
|
@ -66,7 +67,7 @@
|
||||||
.react-datepicker-time__header.react-datepicker-time__header,
|
.react-datepicker-time__header.react-datepicker-time__header,
|
||||||
.react-datepicker-year-header.react-datepicker-year-header,
|
.react-datepicker-year-header.react-datepicker-year-header,
|
||||||
.react-datepicker__day-name.react-datepicker__day-name,
|
.react-datepicker__day-name.react-datepicker__day-name,
|
||||||
.react-datepicker__day:not(:hover).react-datepicker__day:not(:hover),
|
.react-datepicker__day.react-datepicker__day:not(:hover):not(.react-datepicker__day--selected),
|
||||||
.react-datepicker__time-name.react-datepicker__time-name {
|
.react-datepicker__time-name.react-datepicker__time-name {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
@ -84,6 +85,31 @@
|
||||||
color: white !important;
|
color: white !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-list-item.react-datepicker__time-list-item:hover {
|
||||||
|
color: #232323;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-container.react-datepicker__time-container {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-datepicker__time-list.react-datepicker__time-list {
|
||||||
|
/* Forefox scrollbar */
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.5) var(--secondary-color);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
/* Chrome webkit scrollbar */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.react-datepicker-popper.react-datepicker-popper {
|
.react-datepicker-popper.react-datepicker-popper {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
|
|
|
@ -4,12 +4,13 @@ import DatePicker, { ReactDatePickerProps } from 'react-datepicker';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
import { faCalendarAlt as calendarIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { STANDARD_DATE_FORMAT } from '../helpers/date';
|
||||||
import './DateInput.scss';
|
import './DateInput.scss';
|
||||||
|
|
||||||
export type DateInputProps = ReactDatePickerProps;
|
export type DateInputProps = ReactDatePickerProps;
|
||||||
|
|
||||||
export const DateInput = (props: DateInputProps) => {
|
export const DateInput = (props: DateInputProps) => {
|
||||||
const { className, isClearable, selected } = props;
|
const { className, isClearable, selected, dateFormat } = props;
|
||||||
const showCalendarIcon = !isClearable || isNil(selected);
|
const showCalendarIcon = !isClearable || isNil(selected);
|
||||||
const ref = useRef<{ input: HTMLInputElement }>();
|
const ref = useRef<{ input: HTMLInputElement }>();
|
||||||
|
|
||||||
|
@ -17,7 +18,13 @@ export const DateInput = (props: DateInputProps) => {
|
||||||
<div className="date-input-container">
|
<div className="date-input-container">
|
||||||
<DatePicker
|
<DatePicker
|
||||||
{...props}
|
{...props}
|
||||||
dateFormat="yyyy-MM-dd"
|
popperModifiers={[
|
||||||
|
{
|
||||||
|
name: 'arrow',
|
||||||
|
options: { padding: 24 }, // This prevents the arrow to be placed on the very edge, which looks ugly
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
dateFormat={dateFormat ?? STANDARD_DATE_FORMAT}
|
||||||
className={classNames('date-input-container__input form-control', className)}
|
className={classNames('date-input-container__input form-control', className)}
|
||||||
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
|
// @ts-expect-error The DatePicker type definition is wrong. It has a ref prop
|
||||||
ref={ref}
|
ref={ref}
|
|
@ -1,6 +1,6 @@
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
|
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||||
|
|
||||||
export interface DateIntervalDropdownProps {
|
export interface DateIntervalDropdownProps {
|
||||||
active?: DateInterval;
|
active?: DateInterval;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { DropdownBtn } from '../DropdownBtn';
|
import { DropdownBtn } from '../DropdownBtn';
|
||||||
import { rangeOrIntervalToString } from './types';
|
import { rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||||
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
||||||
|
|
||||||
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { endOfDay } from 'date-fns';
|
import { endOfDay } from 'date-fns';
|
||||||
import { DateInput } from '../DateInput';
|
import { DateInput } from './DateInput';
|
||||||
import { DateRange } from './types';
|
import { DateRange } from '../helpers/dateIntervals';
|
||||||
|
|
||||||
interface DateRangeRowProps extends DateRange {
|
interface DateRangeRowProps extends DateRange {
|
||||||
onStartDateChange: (date: Date | null) => void;
|
onStartDateChange: (date: Date | null) => void;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
intervalToDateRange,
|
intervalToDateRange,
|
||||||
rangeIsInterval,
|
rangeIsInterval,
|
||||||
dateRangeIsEmpty,
|
dateRangeIsEmpty,
|
||||||
} from './types';
|
} from '../helpers/dateIntervals';
|
||||||
import { DateRangeRow } from './DateRangeRow';
|
import { DateRangeRow } from './DateRangeRow';
|
||||||
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
||||||
|
|
||||||
|
@ -25,7 +25,9 @@ export const DateRangeSelector = (
|
||||||
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
|
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
|
||||||
) => {
|
) => {
|
||||||
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
||||||
const [activeInterval, setActiveInterval] = useState(initialIntervalIsRange ? initialDateRange : undefined);
|
const [activeInterval, setActiveInterval] = useState<DateInterval | undefined>(
|
||||||
|
initialIntervalIsRange ? initialDateRange : undefined,
|
||||||
|
);
|
||||||
const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
||||||
|
|
||||||
const updateDateRange = (dateRange: DateRange) => {
|
const updateDateRange = (dateRange: DateRange) => {
|
||||||
|
|
15
src/utils/dates/DateTimeInput.tsx
Normal file
15
src/utils/dates/DateTimeInput.tsx
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { ReactDatePickerProps } from 'react-datepicker';
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { DateInput } from './DateInput';
|
||||||
|
import { STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date';
|
||||||
|
|
||||||
|
export type DateTimeInputProps = Omit<ReactDatePickerProps, 'showTimeSelect' | 'dateFormat' | 'timeIntervals'>;
|
||||||
|
|
||||||
|
export const DateTimeInput: FC<DateTimeInputProps> = (props) => (
|
||||||
|
<DateInput
|
||||||
|
{...props}
|
||||||
|
dateFormat={STANDARD_DATE_AND_TIME_FORMAT}
|
||||||
|
showTimeSelect
|
||||||
|
timeIntervals={10}
|
||||||
|
/>
|
||||||
|
);
|
|
@ -1,5 +1,5 @@
|
||||||
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
|
import { parseISO, format as formatDate, getUnixTime, formatDistance } from 'date-fns';
|
||||||
import { isDateObject } from './helpers/date';
|
import { isDateObject, STANDARD_DATE_AND_TIME_FORMAT } from '../helpers/date';
|
||||||
|
|
||||||
export interface TimeProps {
|
export interface TimeProps {
|
||||||
date: Date | string;
|
date: Date | string;
|
||||||
|
@ -7,7 +7,7 @@ export interface TimeProps {
|
||||||
relative?: boolean;
|
relative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Time = ({ date, format = 'yyyy-MM-dd HH:mm', relative = false }: TimeProps) => {
|
export const Time = ({ date, format = STANDARD_DATE_AND_TIME_FORMAT, relative = false }: TimeProps) => {
|
||||||
const dateObject = isDateObject(date) ? date : parseISO(date);
|
const dateObject = isDateObject(date) ? date : parseISO(date);
|
||||||
|
|
||||||
return (
|
return (
|
|
@ -1,6 +1,10 @@
|
||||||
import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
|
import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
|
||||||
import { OptionalString } from '../utils';
|
import { OptionalString } from '../utils';
|
||||||
|
|
||||||
|
export const STANDARD_DATE_FORMAT = 'yyyy-MM-dd';
|
||||||
|
|
||||||
|
export const STANDARD_DATE_AND_TIME_FORMAT = 'yyyy-MM-dd HH:mm';
|
||||||
|
|
||||||
export type DateOrString = Date | string;
|
export type DateOrString = Date | string;
|
||||||
|
|
||||||
type NullableDate = DateOrString | null;
|
type NullableDate = DateOrString | null;
|
||||||
|
@ -15,7 +19,10 @@ const formatDateFromFormat = (date?: NullableDate, theFormat?: string): Optional
|
||||||
return theFormat ? format(date, theFormat) : formatISO(date);
|
return theFormat ? format(date, theFormat) : formatISO(date);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (theFormat = 'yyyy-MM-dd') => (date?: NullableDate) => formatDateFromFormat(date, theFormat);
|
export const formatDate = (theFormat = STANDARD_DATE_FORMAT) => (date?: NullableDate) => formatDateFromFormat(
|
||||||
|
date,
|
||||||
|
theFormat,
|
||||||
|
);
|
||||||
|
|
||||||
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
|
export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date, undefined);
|
||||||
|
|
||||||
|
@ -25,6 +32,8 @@ export const parseDate = (date: string, theFormat: string) => parse(date, theFor
|
||||||
|
|
||||||
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
||||||
|
|
||||||
|
export const dateOrNull = (date?: string): Date | null => (date ? parseISO(date) : null);
|
||||||
|
|
||||||
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
||||||
try {
|
try {
|
||||||
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
||||||
|
|
|
@ -1,21 +1,14 @@
|
||||||
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||||
import { cond, filter, isEmpty, T } from 'ramda';
|
import { cond, filter, isEmpty, T } from 'ramda';
|
||||||
import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from './date';
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
startDate?: Date | null;
|
startDate?: Date | null;
|
||||||
endDate?: Date | null;
|
endDate?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180Days' | 'last365Days';
|
const ALL = 'all';
|
||||||
|
const INTERVAL_TO_STRING_MAP = {
|
||||||
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
|
||||||
|| isEmpty(filter(Boolean, dateRange as any));
|
|
||||||
|
|
||||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
|
||||||
typeof range === 'string';
|
|
||||||
|
|
||||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
yesterday: 'Yesterday',
|
yesterday: 'Yesterday',
|
||||||
last7Days: 'Last 7 days',
|
last7Days: 'Last 7 days',
|
||||||
|
@ -23,10 +16,25 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||||
last90Days: 'Last 90 days',
|
last90Days: 'Last 90 days',
|
||||||
last180Days: 'Last 180 days',
|
last180Days: 'Last 180 days',
|
||||||
last365Days: 'Last 365 days',
|
last365Days: 'Last 365 days',
|
||||||
all: undefined,
|
[ALL]: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP).filter((value) => value !== 'all') as DateInterval[];
|
export type DateInterval = keyof typeof INTERVAL_TO_STRING_MAP;
|
||||||
|
|
||||||
|
const INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[];
|
||||||
|
|
||||||
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
|
||||||
|
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
||||||
|
typeof range === 'string' && INTERVALS.includes(range);
|
||||||
|
|
||||||
|
export const DATE_INTERVALS = INTERVALS.filter((value) => value !== ALL) as DateInterval[];
|
||||||
|
|
||||||
|
export const datesToDateRange = (startDate?: string, endDate?: string): DateRange => ({
|
||||||
|
startDate: dateOrNull(startDate),
|
||||||
|
endDate: dateOrNull(endDate),
|
||||||
|
});
|
||||||
|
|
||||||
const dateRangeToString = (range?: DateRange): string | undefined => {
|
const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
if (!range || dateRangeIsEmpty(range)) {
|
if (!range || dateRangeIsEmpty(range)) {
|
||||||
|
@ -45,7 +53,7 @@ const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
||||||
if (!range || range === 'all') {
|
if (!range || range === ALL) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +68,7 @@ const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysA
|
||||||
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
||||||
|
|
||||||
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
if (!dateInterval || dateInterval === 'all') {
|
if (!dateInterval || dateInterval === ALL) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,6 +103,14 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days'],
|
||||||
[T, () => 'all'],
|
[T, () => ALL],
|
||||||
])();
|
])();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toDateRange = (rangeOrInterval: DateRange | DateInterval): DateRange => {
|
||||||
|
if (rangeIsInterval(rangeOrInterval)) {
|
||||||
|
return intervalToDateRange(rangeOrInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rangeOrInterval;
|
||||||
|
};
|
|
@ -27,7 +27,7 @@ export const useTimeoutToggle = (
|
||||||
return [flag, callback];
|
return [flag, callback];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ToggleResult = [ boolean, () => void, () => void, () => void ];
|
type ToggleResult = [boolean, () => void, () => void, () => void];
|
||||||
|
|
||||||
export const useToggle = (initialValue = false): ToggleResult => {
|
export const useToggle = (initialValue = false): ToggleResult => {
|
||||||
const [flag, setFlag] = useState<boolean>(initialValue);
|
const [flag, setFlag] = useState<boolean>(initialValue);
|
||||||
|
|
|
@ -36,6 +36,6 @@ export const orderToString = <T>(order: Order<T>): string | undefined => (
|
||||||
);
|
);
|
||||||
|
|
||||||
export const stringToOrder = <T>(order: string): Order<T> => {
|
export const stringToOrder = <T>(order: string): Order<T> => {
|
||||||
const [field, dir] = order.split('-') as [ T | undefined, OrderDir | undefined ];
|
const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined];
|
||||||
return { field, dir };
|
return { field, dir };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,17 +1,12 @@
|
||||||
import { Action } from 'redux';
|
import { createAsyncThunk as baseCreateAsyncThunk, AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
|
||||||
|
import { identity } from 'ramda';
|
||||||
|
import { ShlinkState } from '../../container/types';
|
||||||
|
|
||||||
type ActionHandler<State, AT> = (currentState: State, action: AT) => State;
|
export const createAsyncThunk = <Returned, ThunkArg>(
|
||||||
type ActionHandlerMap<State, AT> = Record<string, ActionHandler<State, AT>>;
|
typePrefix: string,
|
||||||
|
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState, serializedErrorType: any }>,
|
||||||
export const buildReducer = <State, AT extends Action>(map: ActionHandlerMap<State, AT>, initialState: State) => (
|
) => baseCreateAsyncThunk(
|
||||||
state: State | undefined,
|
typePrefix,
|
||||||
action: AT,
|
payloadCreator,
|
||||||
): State => {
|
{ serializeError: identity },
|
||||||
const { type } = action;
|
);
|
||||||
const actionHandler = map[type];
|
|
||||||
const currentState = state ?? initialState;
|
|
||||||
|
|
||||||
return actionHandler ? actionHandler(currentState, action) : currentState;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildActionCreator = <T extends string>(type: T) => (): Action<T> => ({ type });
|
|
||||||
|
|
5
src/utils/mixins/text-ellipsis.scss
Normal file
5
src/utils/mixins/text-ellipsis.scss
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
@mixin text-ellipsis() {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
@import '../utils/base';
|
@import '../base';
|
||||||
|
|
||||||
// Light theme colors
|
// Light theme colors
|
||||||
$lightPrimaryColor: #ffffff;
|
$lightPrimaryColor: #ffffff;
|
|
@ -1 +1,3 @@
|
||||||
export type MediaMatcher = (query: string) => MediaQueryList;
|
export type MediaMatcher = (query: string) => MediaQueryList;
|
||||||
|
|
||||||
|
export type Fetch = typeof window.fetch;
|
||||||
|
|
|
@ -21,10 +21,6 @@ type Optional<T> = T | null | undefined;
|
||||||
|
|
||||||
export type OptionalString = Optional<string>;
|
export type OptionalString = Optional<string>;
|
||||||
|
|
||||||
export type RecursivePartial<T> = {
|
|
||||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ? null : value);
|
export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ? null : value);
|
||||||
|
|
||||||
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
import { ShlinkVisitsParams } from '../api/types';
|
||||||
import { DomainVisits as DomainVisitsState } from './reducers/domainVisits';
|
import { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits';
|
||||||
import { ReportExporter } from '../common/services/ReportExporter';
|
import { ReportExporter } from '../common/services/ReportExporter';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
@ -12,7 +12,7 @@ import { VisitsStats } from './VisitsStats';
|
||||||
import { VisitsHeader } from './VisitsHeader';
|
import { VisitsHeader } from './VisitsHeader';
|
||||||
|
|
||||||
export interface DomainVisitsProps extends CommonVisitsProps {
|
export interface DomainVisitsProps extends CommonVisitsProps {
|
||||||
getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
getDomainVisits: (params: LoadDomainVisits) => void;
|
||||||
domainVisits: DomainVisitsState;
|
domainVisits: DomainVisitsState;
|
||||||
cancelGetDomainVisits: () => void;
|
cancelGetDomainVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
|
||||||
const { domain = '' } = useParams();
|
const { domain = '' } = useParams();
|
||||||
const [authority, domainId = authority] = domain.split('_');
|
const [authority, domainId = authority] = domain.split('_');
|
||||||
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
||||||
getDomainVisits(domainId, toApiParams(params), doIntervalFallback);
|
getDomainVisits({ domain: domainId, query: toApiParams(params), doIntervalFallback });
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { ReportExporter } from '../common/services/ReportExporter';
|
import { ReportExporter } from '../common/services/ReportExporter';
|
||||||
import { VisitsStats } from './VisitsStats';
|
import { VisitsStats } from './VisitsStats';
|
||||||
import { NormalizedVisit, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedVisit, VisitsParams } from './types';
|
||||||
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
import { VisitsHeader } from './VisitsHeader';
|
import { VisitsHeader } from './VisitsHeader';
|
||||||
|
import { LoadVisits, VisitsInfo } from './reducers/types';
|
||||||
|
|
||||||
export interface NonOrphanVisitsProps extends CommonVisitsProps {
|
export interface NonOrphanVisitsProps extends CommonVisitsProps {
|
||||||
getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
getNonOrphanVisits: (params: LoadVisits) => void;
|
||||||
nonOrphanVisits: VisitsInfo;
|
nonOrphanVisits: VisitsInfo;
|
||||||
cancelGetNonOrphanVisits: () => void;
|
cancelGetNonOrphanVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -25,7 +25,7 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
|
||||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
||||||
getNonOrphanVisits(toApiParams(params), doIntervalFallback);
|
getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitsStats
|
<VisitsStats
|
||||||
|
|
|
@ -1,20 +1,17 @@
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { ReportExporter } from '../common/services/ReportExporter';
|
import { ReportExporter } from '../common/services/ReportExporter';
|
||||||
import { VisitsStats } from './VisitsStats';
|
import { VisitsStats } from './VisitsStats';
|
||||||
import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedVisit, VisitsParams } from './types';
|
||||||
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
import { VisitsHeader } from './VisitsHeader';
|
import { VisitsHeader } from './VisitsHeader';
|
||||||
|
import { VisitsInfo } from './reducers/types';
|
||||||
|
import { LoadOrphanVisits } from './reducers/orphanVisits';
|
||||||
|
|
||||||
export interface OrphanVisitsProps extends CommonVisitsProps {
|
export interface OrphanVisitsProps extends CommonVisitsProps {
|
||||||
getOrphanVisits: (
|
getOrphanVisits: (params: LoadOrphanVisits) => void;
|
||||||
params?: ShlinkVisitsParams,
|
|
||||||
orphanVisitsType?: OrphanVisitType,
|
|
||||||
doIntervalFallback?: boolean,
|
|
||||||
) => void;
|
|
||||||
orphanVisits: VisitsInfo;
|
orphanVisits: VisitsInfo;
|
||||||
cancelGetOrphanVisits: () => void;
|
cancelGetOrphanVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -28,8 +25,9 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
|
||||||
}: OrphanVisitsProps) => {
|
}: OrphanVisitsProps) => {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
|
||||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits(
|
||||||
getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback);
|
{ query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisitsStats
|
<VisitsStats
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation, useParams } from 'react-router-dom';
|
import { useLocation, useParams } from 'react-router-dom';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { ShlinkVisitsParams } from '../api/types';
|
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { ReportExporter } from '../common/services/ReportExporter';
|
import { ReportExporter } from '../common/services/ReportExporter';
|
||||||
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
import { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
|
||||||
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
|
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
|
||||||
import { VisitsStats } from './VisitsStats';
|
import { VisitsStats } from './VisitsStats';
|
||||||
import { NormalizedVisit, VisitsParams } from './types';
|
import { NormalizedVisit, VisitsParams } from './types';
|
||||||
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
import { urlDecodeShortCode } from '../short-urls/helpers';
|
import { urlDecodeShortCode } from '../short-urls/helpers';
|
||||||
|
import { ShortUrlIdentifier } from '../short-urls/data';
|
||||||
|
|
||||||
export interface ShortUrlVisitsProps extends CommonVisitsProps {
|
export interface ShortUrlVisitsProps extends CommonVisitsProps {
|
||||||
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
getShortUrlVisits: (params: LoadShortUrlVisits) => void;
|
||||||
shortUrlVisits: ShortUrlVisitsState;
|
shortUrlVisits: ShortUrlVisitsState;
|
||||||
getShortUrlDetail: Function;
|
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
|
||||||
shortUrlDetail: ShortUrlDetail;
|
shortUrlDetail: ShortUrlDetail;
|
||||||
cancelGetShortUrlVisits: () => void;
|
cancelGetShortUrlVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -36,15 +36,18 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
|
||||||
const { search } = useLocation();
|
const { search } = useLocation();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { domain } = parseQuery<{ domain?: string }>(search);
|
const { domain } = parseQuery<{ domain?: string }>(search);
|
||||||
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
|
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
|
||||||
getShortUrlVisits(urlDecodeShortCode(shortCode), { ...toApiParams(params), domain }, doIntervalFallback);
|
shortCode: urlDecodeShortCode(shortCode),
|
||||||
|
query: { ...toApiParams(params), domain },
|
||||||
|
doIntervalFallback,
|
||||||
|
});
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
|
||||||
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
|
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
|
||||||
visits,
|
visits,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getShortUrlDetail(urlDecodeShortCode(shortCode), domain);
|
getShortUrlDetail({ shortCode: urlDecodeShortCode(shortCode), domain });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
|
||||||
import { Time } from '../utils/Time';
|
import { Time } from '../utils/dates/Time';
|
||||||
import { ShortUrlVisits } from './reducers/shortUrlVisits';
|
import { ShortUrlVisits } from './reducers/shortUrlVisits';
|
||||||
import { VisitsHeader } from './VisitsHeader';
|
import { VisitsHeader } from './VisitsHeader';
|
||||||
import './ShortUrlVisitsHeader.scss';
|
import './ShortUrlVisitsHeader.scss';
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { ShlinkVisitsParams } from '../api/types';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { useGoBack } from '../utils/helpers/hooks';
|
import { useGoBack } from '../utils/helpers/hooks';
|
||||||
import { ReportExporter } from '../common/services/ReportExporter';
|
import { ReportExporter } from '../common/services/ReportExporter';
|
||||||
import { TagVisits as TagVisitsState } from './reducers/tagVisits';
|
import { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits';
|
||||||
import { TagVisitsHeader } from './TagVisitsHeader';
|
import { TagVisitsHeader } from './TagVisitsHeader';
|
||||||
import { VisitsStats } from './VisitsStats';
|
import { VisitsStats } from './VisitsStats';
|
||||||
import { NormalizedVisit } from './types';
|
import { NormalizedVisit } from './types';
|
||||||
|
@ -13,7 +13,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
|
||||||
import { toApiParams } from './types/helpers';
|
import { toApiParams } from './types/helpers';
|
||||||
|
|
||||||
export interface TagVisitsProps extends CommonVisitsProps {
|
export interface TagVisitsProps extends CommonVisitsProps {
|
||||||
getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
|
getTagVisits: (params: LoadTagVisits) => void;
|
||||||
tagVisits: TagVisitsState;
|
tagVisits: TagVisitsState;
|
||||||
cancelGetTagVisits: () => void;
|
cancelGetTagVisits: () => void;
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const { tag = '' } = useParams();
|
const { tag = '' } = useParams();
|
||||||
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
|
||||||
getTagVisits(tag, toApiParams(params), doIntervalFallback);
|
getTagVisits({ tag, query: toApiParams(params), doIntervalFallback });
|
||||||
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
|
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { isEmpty, propEq, values } from 'ramda';
|
import { isEmpty, pipe, propEq, values } from 'ramda';
|
||||||
import { useState, useEffect, useMemo, FC, useRef, PropsWithChildren } from 'react';
|
import { useState, useEffect, useMemo, FC, useRef, PropsWithChildren } from 'react';
|
||||||
import { Button, Progress, Row } from 'reactstrap';
|
import { Button, Progress, Row } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
@ -8,7 +8,6 @@ import { Route, Routes, Navigate } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { Message } from '../utils/Message';
|
import { Message } from '../utils/Message';
|
||||||
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
|
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
|
@ -19,13 +18,16 @@ import { NavPillItem, NavPills } from '../utils/NavPills';
|
||||||
import { ExportBtn } from '../utils/ExportBtn';
|
import { ExportBtn } from '../utils/ExportBtn';
|
||||||
import { LineChartCard } from './charts/LineChartCard';
|
import { LineChartCard } from './charts/LineChartCard';
|
||||||
import { VisitsTable } from './VisitsTable';
|
import { VisitsTable } from './VisitsTable';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
|
||||||
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
||||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||||
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
||||||
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||||
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||||
|
import { VisitsInfo } from './reducers/types';
|
||||||
|
import { useVisitsQuery } from './helpers/hooks';
|
||||||
|
import { DateInterval, DateRange, toDateRange } from '../utils/helpers/dateIntervals';
|
||||||
|
|
||||||
export type VisitsStatsProps = PropsWithChildren<{
|
export type VisitsStatsProps = PropsWithChildren<{
|
||||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
|
@ -67,19 +69,26 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
isOrphanVisits = false,
|
isOrphanVisits = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
||||||
const [initialInterval, setInitialInterval] = useState<DateInterval>(
|
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
|
||||||
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
const setDates = pipe(
|
||||||
|
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||||
|
dateRange: {
|
||||||
|
startDate: theStartDate ?? undefined,
|
||||||
|
endDate: theEndDate ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updateFiltering,
|
||||||
|
);
|
||||||
|
const initialInterval = useRef<DateRange | DateInterval>(
|
||||||
|
dateRange ?? fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
||||||
);
|
);
|
||||||
const [dateRange, setDateRange] = useState<DateRange>(intervalToDateRange(initialInterval));
|
|
||||||
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
||||||
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
||||||
const [visitsFilter, setVisitsFilter] = useState<VisitsFilter>({});
|
|
||||||
const botsSupported = supportsBotVisits(selectedServer);
|
const botsSupported = supportsBotVisits(selectedServer);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
const buildSectionUrl = (subPath?: string) => {
|
const buildSectionUrl = (subPath?: string) => {
|
||||||
const query = domain ? `?domain=${domain}` : '';
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
|
||||||
return !subPath ? `${query}` : `${subPath}${query}`;
|
return !subPath ? `${query}` : `${subPath}${query}`;
|
||||||
};
|
};
|
||||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
||||||
|
@ -109,12 +118,10 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
useEffect(() => cancelGetVisits, []);
|
useEffect(() => cancelGetVisits, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current);
|
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
|
||||||
|
getVisits({ dateRange: resolvedDateRange, filter: visitsFilter }, isFirstLoad.current);
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
}, [dateRange, visitsFilter]);
|
}, [dateRange, visitsFilter]);
|
||||||
useEffect(() => {
|
|
||||||
fallbackInterval && setInitialInterval(fallbackInterval);
|
|
||||||
}, [fallbackInterval]);
|
|
||||||
|
|
||||||
const renderVisitsContent = () => {
|
const renderVisitsContent = () => {
|
||||||
if (loadingLarge) {
|
if (loadingLarge) {
|
||||||
|
@ -283,9 +290,9 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
updatable
|
updatable
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
initialDateRange={initialInterval}
|
initialDateRange={initialInterval.current}
|
||||||
defaultText="All visits"
|
defaultText="All visits"
|
||||||
onDatesChange={setDateRange}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VisitsFilterDropdown
|
<VisitsFilterDropdown
|
||||||
|
@ -293,7 +300,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
isOrphanVisits={isOrphanVisits}
|
isOrphanVisits={isOrphanVisits}
|
||||||
botsSupported={botsSupported}
|
botsSupported={botsSupported}
|
||||||
selected={visitsFilter}
|
selected={visitsFilter}
|
||||||
onChange={setVisitsFilter}
|
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { determineOrderDir, Order, sortList } from '../utils/helpers/ordering';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { Time } from '../utils/Time';
|
import { Time } from '../utils/dates/Time';
|
||||||
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { MediaMatcher } from '../utils/types';
|
import { MediaMatcher } from '../utils/types';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||||
|
|
|
@ -31,6 +31,7 @@ import { prettify } from '../../utils/helpers/numbers';
|
||||||
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
||||||
import './LineChartCard.scss';
|
import './LineChartCard.scss';
|
||||||
|
import { STANDARD_DATE_FORMAT } from '../../utils/helpers/date';
|
||||||
|
|
||||||
interface LineChartCardProps {
|
interface LineChartCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -65,10 +66,10 @@ const STEP_TO_DIFF_FUNC_MAP: Record<Step, (dateLeft: Date, dateRight: Date) => n
|
||||||
|
|
||||||
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
|
const STEP_TO_DATE_FORMAT: Record<Step, (date: Date) => string> = {
|
||||||
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
|
hourly: (date) => format(date, 'yyyy-MM-dd HH:00'),
|
||||||
daily: (date) => format(date, 'yyyy-MM-dd'),
|
daily: (date) => format(date, STANDARD_DATE_FORMAT),
|
||||||
weekly(date) {
|
weekly(date) {
|
||||||
const firstWeekDay = format(startOfISOWeek(date), 'yyyy-MM-dd');
|
const firstWeekDay = format(startOfISOWeek(date), STANDARD_DATE_FORMAT);
|
||||||
const lastWeekDay = format(endOfISOWeek(date), 'yyyy-MM-dd');
|
const lastWeekDay = format(endOfISOWeek(date), STANDARD_DATE_FORMAT);
|
||||||
|
|
||||||
return `${firstWeekDay} - ${lastWeekDay}`;
|
return `${firstWeekDay} - ${lastWeekDay}`;
|
||||||
},
|
},
|
||||||
|
|
|
@ -37,7 +37,7 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
|
const getSortedPairsForStats = (statsToSort: Stats, sorting: Record<string, string>) => {
|
||||||
const pairs = toPairs(statsToSort);
|
const pairs = toPairs(statsToSort);
|
||||||
const sortedPairs = !order.field ? pairs : sortBy(
|
const sortedPairs = !order.field ? pairs : sortBy(
|
||||||
pipe<StatsRow, string | number, string | number>(
|
pipe<StatsRow[], string | number, string | number>(
|
||||||
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
|
order.field === Object.keys(sorting)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||||
toLowerIfString,
|
toLowerIfString,
|
||||||
),
|
),
|
||||||
|
|
|
@ -14,7 +14,7 @@ interface MapModalProps {
|
||||||
|
|
||||||
const OpenStreetMapTile: FC = () => (
|
const OpenStreetMapTile: FC = () => (
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
attribution='&copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -46,7 +46,12 @@ export const VisitsFilterDropdown = (
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({})}><i>Clear filters</i></DropdownItem>
|
<DropdownItem
|
||||||
|
disabled={!hasValue(selected)}
|
||||||
|
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
|
||||||
|
>
|
||||||
|
<i>Clear filters</i>
|
||||||
|
</DropdownItem>
|
||||||
</DropdownBtn>
|
</DropdownBtn>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
63
src/visits/helpers/hooks.ts
Normal file
63
src/visits/helpers/hooks.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { DeepPartial } from '@reduxjs/toolkit';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { isEmpty, mergeDeepRight, pipe } from 'ramda';
|
||||||
|
import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals';
|
||||||
|
import { OrphanVisitType, VisitsFilter } from '../types';
|
||||||
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { formatIsoDate } from '../../utils/helpers/date';
|
||||||
|
|
||||||
|
interface VisitsQuery {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orphanVisitsType?: OrphanVisitType;
|
||||||
|
excludeBots?: 'true';
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitsFiltering {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
visitsFilter: VisitsFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitsFilteringAndDomain {
|
||||||
|
filtering: VisitsFiltering;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
|
||||||
|
|
||||||
|
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
const { filtering, domain: theDomain } = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => parseQuery<VisitsQuery>(search),
|
||||||
|
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
|
||||||
|
domain,
|
||||||
|
filtering: {
|
||||||
|
dateRange: startDate || endDate ? datesToDateRange(startDate, endDate) : undefined,
|
||||||
|
visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[search],
|
||||||
|
);
|
||||||
|
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
|
||||||
|
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
|
||||||
|
const query: VisitsQuery = {
|
||||||
|
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || undefined,
|
||||||
|
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || undefined,
|
||||||
|
excludeBots: visitsFilter.excludeBots ? 'true' : undefined,
|
||||||
|
orphanVisitsType: visitsFilter.orphanVisitsType,
|
||||||
|
domain: theDomain,
|
||||||
|
};
|
||||||
|
const stringifiedQuery = stringifyQuery(query);
|
||||||
|
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||||
|
|
||||||
|
navigate(queryString, { replace: true, relative: 'route' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return [filtering, updateFiltering];
|
||||||
|
};
|
|
@ -1,10 +1,13 @@
|
||||||
import { flatten, prop, range, splitEvery } from 'ramda';
|
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||||
import { Action, Dispatch } from 'redux';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
|
||||||
import { Visit } from '../types';
|
import { CreateVisit, Visit } from '../types';
|
||||||
|
import { DateInterval, dateToMatchingInterval } from '../../utils/helpers/dateIntervals';
|
||||||
|
import { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
|
||||||
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
|
import { ShlinkState } from '../../container/types';
|
||||||
import { parseApiError } from '../../api/utils';
|
import { parseApiError } from '../../api/utils';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { createNewVisits } from './visitCreation';
|
||||||
import { dateToMatchingInterval } from '../../utils/dates/types';
|
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 5000;
|
const ITEMS_PER_PAGE = 5000;
|
||||||
const PARALLEL_REQUESTS_COUNT = 4;
|
const PARALLEL_REQUESTS_COUNT = 4;
|
||||||
|
@ -15,74 +18,72 @@ const calcProgress = (total: number, current: number): number => (current * 100)
|
||||||
|
|
||||||
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
|
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
|
||||||
type LastVisitLoader = () => Promise<Visit | undefined>;
|
type LastVisitLoader = () => Promise<Visit | undefined>;
|
||||||
interface ActionMap {
|
|
||||||
start: string;
|
interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
|
||||||
large: string;
|
typePrefix: string;
|
||||||
finish: string;
|
createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader];
|
||||||
error: string;
|
getExtraFulfilledPayload: (params: T) => Partial<R>;
|
||||||
progress: string;
|
shouldCancel: (getState: () => ShlinkState) => boolean;
|
||||||
fallbackToInterval: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
|
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
|
||||||
visitsLoader: VisitsLoader,
|
{ typePrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
|
||||||
lastVisitLoader: LastVisitLoader,
|
|
||||||
extraFinishActionData: Partial<T>,
|
|
||||||
actionMap: ActionMap,
|
|
||||||
dispatch: Dispatch,
|
|
||||||
shouldCancel: () => boolean,
|
|
||||||
) => {
|
) => {
|
||||||
dispatch({ type: actionMap.start });
|
const progressChangedAction = createAction<number>(`${typePrefix}/progressChanged`);
|
||||||
|
const largeAction = createAction<void>(`${typePrefix}/large`);
|
||||||
|
const fallbackToIntervalAction = createAction<DateInterval>(`${typePrefix}/fallbackToInterval`);
|
||||||
|
|
||||||
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
|
const asyncThunk = createAsyncThunk(typePrefix, async (params: T, { getState, dispatch }): Promise<R> => {
|
||||||
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
|
const [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
|
||||||
|
|
||||||
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
|
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
|
||||||
if (shouldCancel()) {
|
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await loadVisitsInParallel(pagesBlocks[index]);
|
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
|
||||||
|
if (shouldCancel(getState)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) });
|
const data = await loadVisitsInParallel(pagesBlocks[index]);
|
||||||
|
|
||||||
if (index < pagesBlocks.length - 1) {
|
dispatch(progressChangedAction(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
|
||||||
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
if (index < pagesBlocks.length - 1) {
|
||||||
};
|
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
|
||||||
|
}
|
||||||
|
|
||||||
const loadVisits = async (page = 1) => {
|
|
||||||
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
|
|
||||||
|
|
||||||
// If pagination was not returned, then this is an old shlink version. Just return data
|
|
||||||
if (!pagination || isLastPage(pagination)) {
|
|
||||||
return data;
|
return data;
|
||||||
}
|
};
|
||||||
|
|
||||||
// If there are more pages, make requests in blocks of 4
|
const loadVisits = async (page = 1) => {
|
||||||
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
|
const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
|
||||||
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
|
|
||||||
|
|
||||||
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
|
// If pagination was not returned, then this is an old shlink version. Just return data
|
||||||
dispatch({ type: actionMap.large });
|
if (!pagination || isLastPage(pagination)) {
|
||||||
}
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
return data.concat(await loadPagesBlocks(pagesBlocks));
|
// If there are more pages, make requests in blocks of 4
|
||||||
};
|
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1);
|
||||||
|
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
|
||||||
|
|
||||||
|
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) {
|
||||||
|
dispatch(largeAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.concat(await loadPagesBlocks(pagesBlocks));
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
|
||||||
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]);
|
const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]);
|
||||||
|
|
||||||
dispatch(
|
if (!visits.length && lastVisit) {
|
||||||
!visits.length && lastVisit
|
dispatch(fallbackToIntervalAction(dateToMatchingInterval(lastVisit.date)));
|
||||||
? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) }
|
}
|
||||||
: { ...extraFinishActionData, visits, type: actionMap.finish },
|
|
||||||
);
|
return { ...getExtraFulfilledPayload(params), visits } as any; // TODO Get rid of this casting
|
||||||
} catch (e: any) {
|
});
|
||||||
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
|
||||||
}
|
return { asyncThunk, progressChangedAction, largeAction, fallbackToIntervalAction };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const lastVisitLoaderForLoader = (
|
export const lastVisitLoaderForLoader = (
|
||||||
|
@ -93,5 +94,51 @@ export const lastVisitLoaderForLoader = (
|
||||||
return async () => Promise.resolve(undefined);
|
return async () => Promise.resolve(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]);
|
return async () => loader({ page: 1, itemsPerPage: 1 }).then(({ data }) => data[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VisitsReducerOptions<State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>> {
|
||||||
|
name: string;
|
||||||
|
asyncThunkCreator: AT;
|
||||||
|
initialState: State;
|
||||||
|
filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
|
||||||
|
{ name, asyncThunkCreator, initialState, filterCreatedVisits }: VisitsReducerOptions<State, AT>,
|
||||||
|
) => {
|
||||||
|
const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator;
|
||||||
|
const { reducer, actions } = createSlice({
|
||||||
|
name,
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true }));
|
||||||
|
builder.addCase(asyncThunk.rejected, (_, { error }) => (
|
||||||
|
{ ...initialState, error: true, errorData: parseApiError(error) }
|
||||||
|
));
|
||||||
|
builder.addCase(asyncThunk.fulfilled, (state, { payload }) => (
|
||||||
|
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
|
||||||
|
));
|
||||||
|
|
||||||
|
builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true }));
|
||||||
|
builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress }));
|
||||||
|
builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => (
|
||||||
|
{ ...state, fallbackInterval }
|
||||||
|
));
|
||||||
|
|
||||||
|
builder.addCase(createNewVisits, (state, { payload }) => {
|
||||||
|
const { visits } = state;
|
||||||
|
// @ts-expect-error TODO Fix type inference
|
||||||
|
const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit);
|
||||||
|
|
||||||
|
return !newVisits.length ? state : { ...state, visits: [...newVisits, ...visits] };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { cancelGetVisits } = actions;
|
||||||
|
|
||||||
|
return { reducer, cancelGetVisits };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,40 +1,20 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
|
||||||
import { domainMatches } from '../../short-urls/helpers';
|
import { domainMatches } from '../../short-urls/helpers';
|
||||||
|
import { LoadVisits, VisitsInfo } from './types';
|
||||||
|
|
||||||
export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START';
|
const REDUCER_PREFIX = 'shlink/domainVisits';
|
||||||
export const GET_DOMAIN_VISITS_ERROR = 'shlink/domainVisits/GET_DOMAIN_VISITS_ERROR';
|
|
||||||
export const GET_DOMAIN_VISITS = 'shlink/domainVisits/GET_DOMAIN_VISITS';
|
|
||||||
export const GET_DOMAIN_VISITS_LARGE = 'shlink/domainVisits/GET_DOMAIN_VISITS_LARGE';
|
|
||||||
export const GET_DOMAIN_VISITS_CANCEL = 'shlink/domainVisits/GET_DOMAIN_VISITS_CANCEL';
|
|
||||||
export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = 'shlink/domainVisits/GET_DOMAIN_VISITS_PROGRESS_CHANGED';
|
|
||||||
export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/domainVisits/GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL';
|
|
||||||
|
|
||||||
export const DEFAULT_DOMAIN = 'DEFAULT';
|
export const DEFAULT_DOMAIN = 'DEFAULT';
|
||||||
|
|
||||||
export interface DomainVisits extends VisitsInfo {
|
interface WithDomain {
|
||||||
domain: string;
|
domain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainVisitsAction extends Action<string> {
|
export interface DomainVisits extends VisitsInfo, WithDomain {}
|
||||||
visits: Visit[];
|
|
||||||
domain: string;
|
|
||||||
query?: ShlinkVisitsParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DomainVisitsCombinedAction = DomainVisitsAction
|
export interface LoadDomainVisits extends LoadVisits, WithDomain {}
|
||||||
& VisitsLoadProgressChangedAction
|
|
||||||
& VisitsFallbackIntervalAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState: DomainVisits = {
|
const initialState: DomainVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
@ -46,51 +26,34 @@ const initialState: DomainVisits = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<DomainVisits, DomainVisitsCombinedAction>({
|
export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
|
||||||
[GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
typePrefix: `${REDUCER_PREFIX}/getDomainVisits`,
|
||||||
[GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => {
|
||||||
[GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => (
|
const { getDomainVisits: getVisits } = buildShlinkApiClient(getState);
|
||||||
{ ...state, visits, domain, query, loading: false, loadingLarge: false, error: false }
|
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
||||||
),
|
domain,
|
||||||
[GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
{ ...query, page, itemsPerPage },
|
||||||
[GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
);
|
||||||
[GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
|
||||||
[GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
|
||||||
const { domain, visits, query = {} } = state;
|
|
||||||
const { startDate, endDate } = query;
|
|
||||||
const newVisits = createdVisits
|
|
||||||
.filter(({ shortUrl, visit }) =>
|
|
||||||
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate))
|
|
||||||
.map(({ visit }) => visit);
|
|
||||||
|
|
||||||
return { ...state, visits: [...newVisits, ...visits] };
|
return [visitsLoader, lastVisitLoader];
|
||||||
},
|
},
|
||||||
}, initialState);
|
getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }),
|
||||||
|
shouldCancel: (getState) => getState().domainVisits.cancelLoad,
|
||||||
|
});
|
||||||
|
|
||||||
export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const domainVisitsReducerCreator = (
|
||||||
domain: string,
|
asyncThunkCreator: ReturnType<typeof getDomainVisits>,
|
||||||
query: ShlinkVisitsParams = {},
|
) => createVisitsReducer({
|
||||||
doIntervalFallback = false,
|
name: REDUCER_PREFIX,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
initialState,
|
||||||
const { getDomainVisits: getVisits } = buildShlinkApiClient(getState);
|
// @ts-expect-error TODO Fix type inference
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
asyncThunkCreator,
|
||||||
domain,
|
filterCreatedVisits: ({ domain, query = {} }, createdVisits) => {
|
||||||
{ ...query, page, itemsPerPage },
|
const { startDate, endDate } = query;
|
||||||
);
|
return createdVisits.filter(
|
||||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
|
({ shortUrl, visit }) =>
|
||||||
const shouldCancel = () => getState().domainVisits.cancelLoad;
|
shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate),
|
||||||
const extraFinishActionData: Partial<DomainVisitsAction> = { domain, query };
|
);
|
||||||
const actionMap = {
|
},
|
||||||
start: GET_DOMAIN_VISITS_START,
|
});
|
||||||
large: GET_DOMAIN_VISITS_LARGE,
|
|
||||||
finish: GET_DOMAIN_VISITS,
|
|
||||||
error: GET_DOMAIN_VISITS_ERROR,
|
|
||||||
progress: GET_DOMAIN_VISITS_PROGRESS_CHANGED,
|
|
||||||
fallbackToInterval: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL);
|
|
||||||
|
|
|
@ -1,37 +1,9 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import {
|
|
||||||
Visit,
|
|
||||||
VisitsFallbackIntervalAction,
|
|
||||||
VisitsInfo,
|
|
||||||
VisitsLoadProgressChangedAction,
|
|
||||||
} from '../types';
|
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { VisitsInfo } from './types';
|
||||||
|
|
||||||
export const GET_NON_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_START';
|
const REDUCER_PREFIX = 'shlink/orphanVisits';
|
||||||
export const GET_NON_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_ERROR';
|
|
||||||
export const GET_NON_ORPHAN_VISITS = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS';
|
|
||||||
export const GET_NON_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_LARGE';
|
|
||||||
export const GET_NON_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_CANCEL';
|
|
||||||
export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED';
|
|
||||||
export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL';
|
|
||||||
|
|
||||||
export interface NonOrphanVisitsAction extends Action<string> {
|
|
||||||
visits: Visit[];
|
|
||||||
query?: ShlinkVisitsParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
type NonOrphanVisitsCombinedAction = NonOrphanVisitsAction
|
|
||||||
& VisitsLoadProgressChangedAction
|
|
||||||
& VisitsFallbackIntervalAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState: VisitsInfo = {
|
const initialState: VisitsInfo = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
@ -42,47 +14,28 @@ const initialState: VisitsInfo = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<VisitsInfo, NonOrphanVisitsCombinedAction>({
|
export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
|
||||||
[GET_NON_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
typePrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`,
|
||||||
[GET_NON_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => {
|
||||||
[GET_NON_ORPHAN_VISITS]: (state, { visits, query }) => (
|
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState);
|
||||||
{ ...state, visits, query, loading: false, loadingLarge: false, error: false }
|
const visitsLoader = async (page: number, itemsPerPage: number) =>
|
||||||
),
|
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
|
||||||
[GET_NON_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
|
||||||
[GET_NON_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
|
||||||
[GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
|
||||||
[GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
|
||||||
const { visits, query = {} } = state;
|
|
||||||
const { startDate, endDate } = query;
|
|
||||||
const newVisits = createdVisits
|
|
||||||
.filter(({ visit }) => isBetween(visit.date, startDate, endDate))
|
|
||||||
.map(({ visit }) => visit);
|
|
||||||
|
|
||||||
return { ...state, visits: [...newVisits, ...visits] };
|
return [visitsLoader, lastVisitLoader];
|
||||||
},
|
},
|
||||||
}, initialState);
|
getExtraFulfilledPayload: ({ query = {} }) => ({ query }),
|
||||||
|
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
|
||||||
|
});
|
||||||
|
|
||||||
export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const nonOrphanVisitsReducerCreator = (
|
||||||
query: ShlinkVisitsParams = {},
|
asyncThunkCreator: ReturnType<typeof getNonOrphanVisits>,
|
||||||
doIntervalFallback = false,
|
) => createVisitsReducer({
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
name: REDUCER_PREFIX,
|
||||||
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState);
|
initialState,
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) =>
|
asyncThunkCreator,
|
||||||
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
|
filterCreatedVisits: ({ query = {} }, createdVisits) => {
|
||||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
|
const { startDate, endDate } = query;
|
||||||
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
|
||||||
const extraFinishActionData: Partial<NonOrphanVisitsAction> = { query };
|
},
|
||||||
const actionMap = {
|
});
|
||||||
start: GET_NON_ORPHAN_VISITS_START,
|
|
||||||
large: GET_NON_ORPHAN_VISITS_LARGE,
|
|
||||||
finish: GET_NON_ORPHAN_VISITS,
|
|
||||||
error: GET_NON_ORPHAN_VISITS_ERROR,
|
|
||||||
progress: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED,
|
|
||||||
fallbackToInterval: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL,
|
|
||||||
};
|
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelGetNonOrphanVisits = buildActionCreator(GET_NON_ORPHAN_VISITS_CANCEL);
|
|
||||||
|
|
|
@ -1,41 +1,16 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
import { OrphanVisit, OrphanVisitType } 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 { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
|
||||||
import { isOrphanVisit } from '../types/helpers';
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { LoadVisits, VisitsInfo } from './types';
|
||||||
|
|
||||||
export const GET_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_ORPHAN_VISITS_START';
|
const REDUCER_PREFIX = 'shlink/orphanVisits';
|
||||||
export const GET_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_ORPHAN_VISITS_ERROR';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export interface OrphanVisitsAction extends Action<string> {
|
export interface LoadOrphanVisits extends LoadVisits {
|
||||||
visits: Visit[];
|
orphanVisitsType?: OrphanVisitType;
|
||||||
query?: ShlinkVisitsParams;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
|
||||||
& VisitsLoadProgressChangedAction
|
|
||||||
& VisitsFallbackIntervalAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState: VisitsInfo = {
|
const initialState: VisitsInfo = {
|
||||||
visits: [],
|
visits: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
|
@ -45,55 +20,34 @@ const initialState: VisitsInfo = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
|
||||||
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
|
||||||
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
|
||||||
[GET_ORPHAN_VISITS]: (state, { visits, query }) => (
|
|
||||||
{ ...state, visits, query, loading: false, loadingLarge: 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;
|
|
||||||
const newVisits = createdVisits
|
|
||||||
.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate))
|
|
||||||
.map(({ visit }) => visit);
|
|
||||||
|
|
||||||
return { ...state, visits: [...newVisits, ...visits] };
|
|
||||||
},
|
|
||||||
}, initialState);
|
|
||||||
|
|
||||||
const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
|
const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
|
||||||
!orphanVisitsType || orphanVisitsType === visit.type;
|
!orphanVisitsType || orphanVisitsType === visit.type;
|
||||||
|
|
||||||
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
|
||||||
query: ShlinkVisitsParams = {},
|
typePrefix: `${REDUCER_PREFIX}/getOrphanVisits`,
|
||||||
orphanVisitsType?: OrphanVisitType,
|
createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => {
|
||||||
doIntervalFallback = false,
|
const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState);
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage })
|
||||||
const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState);
|
.then((result) => {
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage })
|
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
|
||||||
.then((result) => {
|
return { ...result, data: visits };
|
||||||
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
|
});
|
||||||
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits);
|
||||||
|
|
||||||
return { ...result, data: visits };
|
return [visitsLoader, lastVisitLoader];
|
||||||
});
|
},
|
||||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits);
|
getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }),
|
||||||
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
|
||||||
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
|
});
|
||||||
const actionMap = {
|
|
||||||
start: GET_ORPHAN_VISITS_START,
|
|
||||||
large: GET_ORPHAN_VISITS_LARGE,
|
|
||||||
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, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
export const orphanVisitsReducerCreator = (
|
||||||
};
|
asyncThunkCreator: ReturnType<typeof getOrphanVisits>,
|
||||||
|
) => createVisitsReducer({
|
||||||
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
name: REDUCER_PREFIX,
|
||||||
|
initialState,
|
||||||
|
asyncThunkCreator,
|
||||||
|
filterCreatedVisits: ({ query = {} }, createdVisits) => {
|
||||||
|
const { startDate, endDate } = query;
|
||||||
|
return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
@ -1,37 +1,18 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||||
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
|
||||||
import { ShortUrlIdentifier } from '../../short-urls/data';
|
import { ShortUrlIdentifier } from '../../short-urls/data';
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
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 { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { LoadVisits, VisitsInfo } from './types';
|
||||||
|
|
||||||
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START';
|
const REDUCER_PREFIX = 'shlink/shortUrlVisits';
|
||||||
export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
||||||
|
|
||||||
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
export interface LoadShortUrlVisits extends LoadVisits {
|
||||||
visits: Visit[];
|
shortCode: string;
|
||||||
query?: ShlinkVisitsParams;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
|
||||||
& VisitsLoadProgressChangedAction
|
|
||||||
& VisitsFallbackIntervalAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState: ShortUrlVisits = {
|
const initialState: ShortUrlVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
|
@ -43,63 +24,39 @@ const initialState: ShortUrlVisits = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
|
||||||
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
typePrefix: `${REDUCER_PREFIX}/getShortUrlVisits`,
|
||||||
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => {
|
||||||
[GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({
|
const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState);
|
||||||
...state,
|
const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits(
|
||||||
visits,
|
shortCode,
|
||||||
shortCode,
|
{ ...query, page, itemsPerPage },
|
||||||
domain,
|
);
|
||||||
query,
|
const lastVisitLoader = lastVisitLoaderForLoader(
|
||||||
loading: false,
|
doIntervalFallback,
|
||||||
loadingLarge: false,
|
async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }),
|
||||||
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;
|
|
||||||
const newVisits = createdVisits
|
|
||||||
.filter(
|
|
||||||
({ shortUrl, visit }) =>
|
|
||||||
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
|
|
||||||
)
|
|
||||||
.map(({ visit }) => visit);
|
|
||||||
|
|
||||||
return newVisits.length === 0 ? state : { ...state, visits: [...newVisits, ...visits] };
|
return [visitsLoader, lastVisitLoader];
|
||||||
},
|
},
|
||||||
}, initialState);
|
getExtraFulfilledPayload: ({ shortCode, query = {} }: LoadShortUrlVisits) => (
|
||||||
|
{ shortCode, query, domain: query.domain }
|
||||||
|
),
|
||||||
|
shouldCancel: (getState) => getState().shortUrlVisits.cancelLoad,
|
||||||
|
});
|
||||||
|
|
||||||
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const shortUrlVisitsReducerCreator = (
|
||||||
shortCode: string,
|
asyncThunkCreator: ReturnType<typeof getShortUrlVisits>,
|
||||||
query: ShlinkVisitsParams = {},
|
) => createVisitsReducer({
|
||||||
doIntervalFallback = false,
|
name: REDUCER_PREFIX,
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
initialState,
|
||||||
const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState);
|
// @ts-expect-error TODO Fix type inference
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits(
|
asyncThunkCreator,
|
||||||
shortCode,
|
filterCreatedVisits: ({ shortCode, domain, query = {} }: ShortUrlVisits, createdVisits) => {
|
||||||
{ ...query, page, itemsPerPage },
|
const { startDate, endDate } = query;
|
||||||
);
|
return createdVisits.filter(
|
||||||
const lastVisitLoader = lastVisitLoaderForLoader(
|
({ shortUrl, visit }) =>
|
||||||
doIntervalFallback,
|
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
|
||||||
async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }),
|
);
|
||||||
);
|
},
|
||||||
const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
|
});
|
||||||
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
|
|
||||||
const actionMap = {
|
|
||||||
start: GET_SHORT_URL_VISITS_START,
|
|
||||||
large: GET_SHORT_URL_VISITS_LARGE,
|
|
||||||
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, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL);
|
|
||||||
|
|
|
@ -1,37 +1,17 @@
|
||||||
import { Action, Dispatch } from 'redux';
|
|
||||||
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { GetState } from '../../container/types';
|
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
|
||||||
import { isBetween } from '../../utils/helpers/date';
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
|
import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { LoadVisits, VisitsInfo } from './types';
|
||||||
|
|
||||||
export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START';
|
const REDUCER_PREFIX = 'shlink/tagVisits';
|
||||||
export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR';
|
|
||||||
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';
|
|
||||||
|
|
||||||
export interface TagVisits extends VisitsInfo {
|
interface WithTag {
|
||||||
tag: string;
|
tag: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TagVisitsAction extends Action<string> {
|
export interface TagVisits extends VisitsInfo, WithTag {}
|
||||||
visits: Visit[];
|
|
||||||
tag: string;
|
|
||||||
query?: ShlinkVisitsParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TagsVisitsCombinedAction = TagVisitsAction
|
export interface LoadTagVisits extends LoadVisits, WithTag {}
|
||||||
& VisitsLoadProgressChangedAction
|
|
||||||
& VisitsFallbackIntervalAction
|
|
||||||
& CreateVisitsAction
|
|
||||||
& ApiErrorAction;
|
|
||||||
|
|
||||||
const initialState: TagVisits = {
|
const initialState: TagVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
|
@ -43,50 +23,31 @@ const initialState: TagVisits = {
|
||||||
progress: 0,
|
progress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
|
||||||
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
typePrefix: `${REDUCER_PREFIX}/getTagVisits`,
|
||||||
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => {
|
||||||
[GET_TAG_VISITS]: (state, { visits, tag, query }) => (
|
const { getTagVisits: getVisits } = buildShlinkApiClient(getState);
|
||||||
{ ...state, visits, tag, query, loading: false, loadingLarge: false, error: false }
|
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
||||||
),
|
tag,
|
||||||
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
{ ...query, page, itemsPerPage },
|
||||||
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
);
|
||||||
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params));
|
||||||
[GET_TAG_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
|
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
|
||||||
const { tag, visits, query = {} } = state;
|
|
||||||
const { startDate, endDate } = query;
|
|
||||||
const newVisits = createdVisits
|
|
||||||
.filter(({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate))
|
|
||||||
.map(({ visit }) => visit);
|
|
||||||
|
|
||||||
return { ...state, visits: [...newVisits, ...visits] };
|
return [visitsLoader, lastVisitLoader];
|
||||||
},
|
},
|
||||||
}, initialState);
|
getExtraFulfilledPayload: ({ tag, query = {} }: LoadTagVisits) => ({ tag, query }),
|
||||||
|
shouldCancel: (getState) => getState().tagVisits.cancelLoad,
|
||||||
|
});
|
||||||
|
|
||||||
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const tagVisitsReducerCreator = (asyncThunkCreator: ReturnType<typeof getTagVisits>) => createVisitsReducer({
|
||||||
tag: string,
|
name: REDUCER_PREFIX,
|
||||||
query: ShlinkVisitsParams = {},
|
initialState,
|
||||||
doIntervalFallback = false,
|
// @ts-expect-error TODO Fix type inference
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
asyncThunkCreator,
|
||||||
const { getTagVisits: getVisits } = buildShlinkApiClient(getState);
|
filterCreatedVisits: ({ tag, query = {} }: TagVisits, createdVisits) => {
|
||||||
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
|
const { startDate, endDate } = query;
|
||||||
tag,
|
return createdVisits.filter(
|
||||||
{ ...query, page, itemsPerPage },
|
({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate),
|
||||||
);
|
);
|
||||||
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params));
|
},
|
||||||
const shouldCancel = () => getState().tagVisits.cancelLoad;
|
});
|
||||||
const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
|
|
||||||
const actionMap = {
|
|
||||||
start: GET_TAG_VISITS_START,
|
|
||||||
large: GET_TAG_VISITS_LARGE,
|
|
||||||
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, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL);
|
|
||||||
|
|
26
src/visits/reducers/types/index.ts
Normal file
26
src/visits/reducers/types/index.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import { ShlinkVisitsParams } from '../../../api/types';
|
||||||
|
import { DateInterval } from '../../../utils/helpers/dateIntervals';
|
||||||
|
import { ProblemDetailsError } from '../../../api/types/errors';
|
||||||
|
import { Visit } from '../../types';
|
||||||
|
|
||||||
|
export interface VisitsInfo {
|
||||||
|
visits: Visit[];
|
||||||
|
loading: boolean;
|
||||||
|
loadingLarge: boolean;
|
||||||
|
error: boolean;
|
||||||
|
errorData?: ProblemDetailsError;
|
||||||
|
progress: number;
|
||||||
|
cancelLoad: boolean;
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
|
fallbackInterval?: DateInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoadVisits {
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
|
doIntervalFallback?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VisitsLoaded<T = {}> = T & {
|
||||||
|
visits: Visit[];
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue