mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 17:40:23 +03:00
commit
42152c6872
133 changed files with 38621 additions and 6884 deletions
|
@ -16,6 +16,7 @@
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"],
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"complexity": "off"
|
"complexity": "off",
|
||||||
|
"@typescript-eslint/no-unnecessary-type-assertion": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
54
.github/workflows/ci.yml
vendored
54
.github/workflows/ci.yml
vendored
|
@ -5,54 +5,12 @@ on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
ci:
|
||||||
runs-on: ubuntu-20.04
|
uses: shlinkio/github-actions/.github/workflows/web-app-ci.yml@main
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Use node.js 14.15
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 16.13
|
||||||
- run: npm ci
|
with-mutation-tests: true
|
||||||
- run: npm run lint
|
publish-coverage: true
|
||||||
|
|
||||||
unit-tests:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- name: Use node.js 14.15
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 14.15
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run test:ci
|
|
||||||
- name: Publish coverage
|
|
||||||
uses: codecov/codecov-action@v1
|
|
||||||
with:
|
|
||||||
file: ./coverage/clover.xml
|
|
||||||
|
|
||||||
mutation-tests:
|
|
||||||
continue-on-error: true
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # needed so that the main branch is also fetched
|
|
||||||
- name: Use node.js 14.15
|
|
||||||
uses: actions/setup-node@v1
|
|
||||||
with:
|
|
||||||
node-version: 14.15
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run mutate -- --mutate=$(git diff origin/main --name-only | grep -E 'src\/(.*).(ts|tsx)$' | paste -sd ",")
|
|
||||||
|
|
||||||
build-docker-image:
|
|
||||||
runs-on: ubuntu-20.04
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v2
|
|
||||||
- run: docker build -t shlink-web-client:test .
|
|
||||||
|
|
4
.github/workflows/deploy-preview.yml
vendored
4
.github/workflows/deploy-preview.yml
vendored
|
@ -13,10 +13,10 @@ jobs:
|
||||||
with:
|
with:
|
||||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
ref: ${{ github.event.pull_request.head.ref }}
|
ref: ${{ github.event.pull_request.head.ref }}
|
||||||
- name: Use node.js 14.15
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 16.13
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci && \
|
npm ci && \
|
||||||
|
|
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
|
@ -11,10 +11,10 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
- name: Use node.js 14.15
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v1
|
uses: actions/setup-node@v1
|
||||||
with:
|
with:
|
||||||
node-version: 14.15
|
node-version: 16.13
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
run: npm ci && npm run build ${GITHUB_REF#refs/tags/v}
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
|
|
24
CHANGELOG.md
24
CHANGELOG.md
|
@ -4,6 +4,30 @@ 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.4.0] - 2021-11-11
|
||||||
|
### Added
|
||||||
|
* [#496](https://github.com/shlinkio/shlink-web-client/issues/496) Allowed to select "all visits" as the default interval for visits.
|
||||||
|
* [#500](https://github.com/shlinkio/shlink-web-client/issues/500) Allowed to set the `forwardQuery` flag when creating/editing short URLs on a Shlink v2.9.0 server.
|
||||||
|
* [#508](https://github.com/shlinkio/shlink-web-client/issues/508) Added new servers management section.
|
||||||
|
* [#490](https://github.com/shlinkio/shlink-web-client/issues/490) Now a server can be marked as auto-connect, skipping home screen when that happens.
|
||||||
|
* [#492](https://github.com/shlinkio/shlink-web-client/issues/492) Improved tags table, by supporting sorting by column and making the header sticky.
|
||||||
|
* [#515](https://github.com/shlinkio/shlink-web-client/issues/515) Allowed to sort tags even when using the cards display mode.
|
||||||
|
* [#518](https://github.com/shlinkio/shlink-web-client/issues/518) Improved short URLs list filtering by moving selected tags, search text and dates to the query string, allowing to navigate back and forth or even bookmark filters.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Moved ci workflow to external repo and reused
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* [#252](https://github.com/shlinkio/shlink-web-client/issues/252) Fixed visits coming from mercure being added in real time, even when selected date interval does not match tha visit's date.
|
||||||
|
* [#48](https://github.com/shlinkio/shlink-web-client/issues/48) Fixed error when selected page gets out of range after filtering short URLs list by text, tags or dates. Now the page is reset to 1 in any of those cases.
|
||||||
|
|
||||||
|
|
||||||
## [3.3.2] - 2021-10-17
|
## [3.3.2] - 2021-10-17
|
||||||
### Added
|
### Added
|
||||||
* *Nothing*
|
* *Nothing*
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:14.17-alpine as node
|
FROM node:16.13-alpine as node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION ${VERSION}
|
ENV VERSION ${VERSION}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
# shlink-web-client
|
# shlink-web-client
|
||||||
|
|
||||||
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/main?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
[![Build Status](https://img.shields.io/github/workflow/status/shlinkio/shlink-web-client/Continuous%20integration/develop?logo=github&style=flat-square)](https://github.com/shlinkio/shlink-web-client/actions?query=workflow%3A%22Continuous+integration%22)
|
||||||
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/main?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
[![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink-web-client/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink-web-client)
|
||||||
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
[![GitHub release](https://img.shields.io/github/release/shlinkio/shlink-web-client.svg?style=flat-square)](https://github.com/shlinkio/shlink-web-client/releases/latest)
|
||||||
[![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)
|
||||||
|
|
|
@ -3,7 +3,7 @@ version: '3'
|
||||||
services:
|
services:
|
||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
image: node:14.17-alpine
|
image: node:16.13-alpine
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|
|
@ -10,7 +10,7 @@ module.exports = {
|
||||||
coverageThreshold: {
|
coverageThreshold: {
|
||||||
global: {
|
global: {
|
||||||
statements: 85,
|
statements: 85,
|
||||||
branches: 75,
|
branches: 80,
|
||||||
functions: 80,
|
functions: 80,
|
||||||
lines: 85,
|
lines: 85,
|
||||||
},
|
},
|
||||||
|
|
42466
package-lock.json
generated
42466
package-lock.json
generated
File diff suppressed because it is too large
Load diff
24
package.json
24
package.json
|
@ -69,13 +69,13 @@
|
||||||
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.13.8",
|
||||||
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
"@babel/plugin-proposal-optional-chaining": "^7.13.8",
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
"@shlinkio/eslint-config-js-coding-standard": "~1.2.2",
|
||||||
"@stryker-mutator/core": "^5.0.0",
|
"@stryker-mutator/core": "^5.4.1",
|
||||||
"@stryker-mutator/jest-runner": "^5.0.0",
|
"@stryker-mutator/jest-runner": "^5.4.1",
|
||||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
"@stryker-mutator/typescript-checker": "^5.4.1",
|
||||||
"@svgr/webpack": "^5.5.0",
|
"@svgr/webpack": "^5.5.0",
|
||||||
"@types/classnames": "^2.2.11",
|
"@types/classnames": "^2.2.11",
|
||||||
"@types/enzyme": "^3.10.8",
|
"@types/enzyme": "^3.10.10",
|
||||||
"@types/jest": "^26.0.20",
|
"@types/jest": "^27.0.2",
|
||||||
"@types/leaflet": "^1.5.23",
|
"@types/leaflet": "^1.5.23",
|
||||||
"@types/qs": "^6.9.5",
|
"@types/qs": "^6.9.5",
|
||||||
"@types/ramda": "^0.27.38",
|
"@types/ramda": "^0.27.38",
|
||||||
|
@ -89,18 +89,18 @@
|
||||||
"@types/react-router-dom": "^5.1.7",
|
"@types/react-router-dom": "^5.1.7",
|
||||||
"@types/react-tag-autocomplete": "^6.1.0",
|
"@types/react-tag-autocomplete": "^6.1.0",
|
||||||
"@types/uuid": "^8.3.0",
|
"@types/uuid": "^8.3.0",
|
||||||
"@wojtekmaj/enzyme-adapter-react-17": "^0.3.1",
|
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.5",
|
||||||
"adm-zip": "^0.4.16",
|
"adm-zip": "^0.4.16",
|
||||||
"autoprefixer": "^10.0.2",
|
"autoprefixer": "^10.0.2",
|
||||||
"babel-core": "7.0.0-bridge.0",
|
"babel-core": "7.0.0-bridge.0",
|
||||||
"babel-jest": "^26.6.3",
|
"babel-jest": "^27.3.1",
|
||||||
"babel-loader": "^8.2.1",
|
"babel-loader": "^8.2.1",
|
||||||
"babel-plugin-named-asset-import": "^0.3.7",
|
"babel-plugin-named-asset-import": "^0.3.7",
|
||||||
"babel-preset-react-app": "^10.0.0",
|
"babel-preset-react-app": "^10.0.0",
|
||||||
"babel-runtime": "^6.26.0",
|
"babel-runtime": "^6.26.0",
|
||||||
"bfj": "^7.0.2",
|
"bfj": "^7.0.2",
|
||||||
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
"case-sensitive-paths-webpack-plugin": "^2.3.0",
|
||||||
"chalk": "^4.1.0",
|
"chalk": "^4.1.2",
|
||||||
"css-loader": "^5.0.1",
|
"css-loader": "^5.0.1",
|
||||||
"dart-sass": "^1.25.0",
|
"dart-sass": "^1.25.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.2.0",
|
||||||
|
@ -113,9 +113,9 @@
|
||||||
"fs-extra": "^9.0.1",
|
"fs-extra": "^9.0.1",
|
||||||
"html-webpack-plugin": "^4.5.0",
|
"html-webpack-plugin": "^4.5.0",
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^27.3.1",
|
||||||
"jest-pnp-resolver": "^1.2.2",
|
"jest-pnp-resolver": "^1.2.2",
|
||||||
"jest-resolve": "^26.6.2",
|
"jest-resolve": "^27.3.1",
|
||||||
"mini-css-extract-plugin": "^1.3.1",
|
"mini-css-extract-plugin": "^1.3.1",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
"optimize-css-assets-webpack-plugin": "^5.0.4",
|
||||||
|
@ -141,9 +141,9 @@
|
||||||
"stylelint-scss": "^3.18.0",
|
"stylelint-scss": "^3.18.0",
|
||||||
"sw-precache-webpack-plugin": "^1.0.0",
|
"sw-precache-webpack-plugin": "^1.0.0",
|
||||||
"terser-webpack-plugin": "^4.2.3",
|
"terser-webpack-plugin": "^4.2.3",
|
||||||
"ts-jest": "^26.5.2",
|
"ts-jest": "^27.0.7",
|
||||||
"ts-mockery": "^1.2.0",
|
"ts-mockery": "^1.2.0",
|
||||||
"typescript": "^4.2.2",
|
"typescript": "^4.4.4",
|
||||||
"url-loader": "^4.1.1",
|
"url-loader": "^4.1.1",
|
||||||
"webpack": "^4.44.2",
|
"webpack": "^4.44.2",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { isEmpty, isNil, reject } from 'ramda';
|
import { isEmpty, isNil, reject } from 'ramda';
|
||||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
|
||||||
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 {
|
||||||
|
@ -17,6 +16,7 @@ import {
|
||||||
ShlinkVisitsOverview,
|
ShlinkVisitsOverview,
|
||||||
ShlinkEditDomainRedirects,
|
ShlinkEditDomainRedirects,
|
||||||
ShlinkDomainRedirects,
|
ShlinkDomainRedirects,
|
||||||
|
ShlinkShortUrlsListParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { stringifyQuery } from '../../utils/helpers/query';
|
import { stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
|
@ -34,7 +34,7 @@ export default class ShlinkApiClient {
|
||||||
this.apiVersion = 2;
|
this.apiVersion = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly listShortUrls = async (params: ShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
public readonly listShortUrls = async (params: ShlinkShortUrlsListParams = {}): Promise<ShlinkShortUrlsResponse> =>
|
||||||
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
this.performRequest<{ shortUrls: ShlinkShortUrlsResponse }>('/short-urls', 'GET', params)
|
||||||
.then(({ data }) => data.shortUrls);
|
.then(({ data }) => data.shortUrls);
|
||||||
|
|
||||||
|
@ -125,7 +125,7 @@ export default class ShlinkApiClient {
|
||||||
data: body,
|
data: body,
|
||||||
paramsSerializer: stringifyQuery,
|
paramsSerializer: stringifyQuery,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
const { response } = e;
|
const { response } = e;
|
||||||
|
|
||||||
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
// Due to a bug on all previous Shlink versions, requests to non-matching URLs will always result on a CORS error
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { Visit } from '../../visits/types';
|
import { Visit } from '../../visits/types';
|
||||||
import { OptionalString } from '../../utils/utils';
|
import { OptionalString } from '../../utils/utils';
|
||||||
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
import { ShortUrl, ShortUrlMeta } from '../../short-urls/data';
|
||||||
|
import { OrderBy } from '../../short-urls/reducers/shortUrlsListParams';
|
||||||
|
|
||||||
export interface ShlinkShortUrlsResponse {
|
export interface ShlinkShortUrlsResponse {
|
||||||
data: ShortUrl[];
|
data: ShortUrl[];
|
||||||
|
@ -25,12 +26,12 @@ interface ShlinkTagsStats {
|
||||||
|
|
||||||
export interface ShlinkTags {
|
export interface ShlinkTags {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
stats: ShlinkTagsStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkTagsResponse {
|
export interface ShlinkTagsResponse {
|
||||||
data: string[];
|
data: string[];
|
||||||
stats?: ShlinkTagsStats[]; // Is only optional in Shlink older than v2.2
|
stats: ShlinkTagsStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShlinkPaginator {
|
export interface ShlinkPaginator {
|
||||||
|
@ -85,12 +86,21 @@ export interface ShlinkDomainsResponse {
|
||||||
data: ShlinkDomain[];
|
data: ShlinkDomain[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ShlinkShortUrlsListParams {
|
||||||
|
page?: string;
|
||||||
|
itemsPerPage?: number;
|
||||||
|
tags?: string[];
|
||||||
|
searchTerm?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orderBy?: OrderBy;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProblemDetailsError {
|
export interface ProblemDetailsError {
|
||||||
type: string;
|
type: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
|
||||||
[extraProps: string]: any;
|
[extraProps: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ const App = (
|
||||||
CreateServer: FC,
|
CreateServer: FC,
|
||||||
EditServer: FC,
|
EditServer: FC,
|
||||||
Settings: FC,
|
Settings: FC,
|
||||||
|
ManageServers: FC,
|
||||||
ShlinkVersionsContainer: FC,
|
ShlinkVersionsContainer: FC,
|
||||||
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
) => ({ fetchServers, servers, settings, appUpdated, resetAppUpdate }: AppProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -43,6 +44,7 @@ const App = (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Home} />
|
<Route exact path="/" component={Home} />
|
||||||
<Route exact path="/settings" component={Settings} />
|
<Route exact path="/settings" component={Settings} />
|
||||||
|
<Route exact path="/manage-servers" component={ManageServers} />
|
||||||
<Route exact path="/server/create" component={CreateServer} />
|
<Route exact path="/server/create" component={CreateServer} />
|
||||||
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
<Route exact path="/server/:serverId/edit" component={EditServer} />
|
||||||
<Route path="/server/:serverId" component={MenuLayout} />
|
<Route path="/server/:serverId" component={MenuLayout} />
|
||||||
|
|
|
@ -14,6 +14,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
'CreateServer',
|
'CreateServer',
|
||||||
'EditServer',
|
'EditServer',
|
||||||
'Settings',
|
'Settings',
|
||||||
|
'ManageServers',
|
||||||
'ShlinkVersionsContainer',
|
'ShlinkVersionsContainer',
|
||||||
);
|
);
|
||||||
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
bottle.decorator('App', connect([ 'servers', 'settings', 'appUpdated' ], [ 'fetchServers', 'resetAppUpdate' ]));
|
||||||
|
|
|
@ -40,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
||||||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||||
) => {
|
) => {
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const hasId = isServerWithId(selectedServer);
|
||||||
|
const serverId = hasId ? selectedServer.id : '';
|
||||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||||
const asideClass = classNames('aside-menu', {
|
const asideClass = classNames('aside-menu', {
|
||||||
'aside-menu--hidden': !showOnMobile,
|
'aside-menu--hidden': !showOnMobile,
|
||||||
|
@ -77,7 +78,7 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||||
<span className="aside-menu__item-text">Edit this server</span>
|
<span className="aside-menu__item-text">Edit this server</span>
|
||||||
</AsideMenuItem>
|
</AsideMenuItem>
|
||||||
{isServerWithId(selectedServer) && (
|
{hasId && (
|
||||||
<DeleteServerButton
|
<DeleteServerButton
|
||||||
className="aside-menu__item aside-menu__item--danger"
|
className="aside-menu__item aside-menu__item--danger"
|
||||||
textClassName="aside-menu__item-text"
|
textClassName="aside-menu__item-text"
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, RouteChildrenProps } from 'react-router-dom';
|
||||||
import { Card, Row } from 'reactstrap';
|
import { Card, Row } from 'reactstrap';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
@ -9,14 +10,21 @@ import { ServersMap } from '../servers/data';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './Home.scss';
|
import './Home.scss';
|
||||||
|
|
||||||
export interface HomeProps {
|
export interface HomeProps extends RouteChildrenProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Home = ({ servers }: HomeProps) => {
|
const Home = ({ servers, history }: HomeProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const hasServers = !isEmpty(serversList);
|
const hasServers = !isEmpty(serversList);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Try to redirect to the first server marked as auto-connect
|
||||||
|
const autoConnectServer = serversList.find(({ autoConnect }) => autoConnect);
|
||||||
|
|
||||||
|
autoConnectServer && history.push(`/server/${autoConnectServer.id}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<div className="home">
|
||||||
<Card className="home__main-card">
|
<Card className="home__main-card">
|
||||||
|
|
|
@ -13,7 +13,7 @@ import './MenuLayout.scss';
|
||||||
|
|
||||||
const MenuLayout = (
|
const MenuLayout = (
|
||||||
TagsList: FC,
|
TagsList: FC,
|
||||||
ShortUrls: FC,
|
ShortUrlsList: FC,
|
||||||
AsideMenu: FC<AsideMenuProps>,
|
AsideMenu: FC<AsideMenuProps>,
|
||||||
CreateShortUrl: FC,
|
CreateShortUrl: FC,
|
||||||
ShortUrlVisits: FC,
|
ShortUrlVisits: FC,
|
||||||
|
@ -49,7 +49,7 @@ const MenuLayout = (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
<Redirect exact from="/server/:serverId" to="/server/:serverId/overview" />
|
||||||
<Route exact path="/server/:serverId/overview" component={Overview} />
|
<Route exact path="/server/:serverId/overview" component={Overview} />
|
||||||
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrls} />
|
<Route exact path="/server/:serverId/list-short-urls/:page" component={ShortUrlsList} />
|
||||||
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
<Route exact path="/server/:serverId/create-short-url" component={CreateShortUrl} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
<Route path="/server/:serverId/short-code/:shortCode/visits" component={ShortUrlVisits} />
|
||||||
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
<Route path="/server/:serverId/short-code/:shortCode/edit" component={EditShortUrl} />
|
||||||
|
|
|
@ -28,13 +28,14 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
|
|
||||||
bottle.serviceFactory('Home', () => Home);
|
bottle.serviceFactory('Home', () => Home);
|
||||||
bottle.decorator('Home', withoutSelectedServer);
|
bottle.decorator('Home', withoutSelectedServer);
|
||||||
|
bottle.decorator('Home', withRouter);
|
||||||
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
bottle.decorator('Home', connect([ 'servers' ], [ 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory(
|
bottle.serviceFactory(
|
||||||
'MenuLayout',
|
'MenuLayout',
|
||||||
MenuLayout,
|
MenuLayout,
|
||||||
'TagsList',
|
'TagsList',
|
||||||
'ShortUrls',
|
'ShortUrlsList',
|
||||||
'AsideMenu',
|
'AsideMenu',
|
||||||
'CreateShortUrl',
|
'CreateShortUrl',
|
||||||
'ShortUrlVisits',
|
'ShortUrlVisits',
|
||||||
|
@ -45,7 +46,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
'EditShortUrl',
|
'EditShortUrl',
|
||||||
'ManageDomains',
|
'ManageDomains',
|
||||||
);
|
);
|
||||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
bottle.decorator('MenuLayout', connect([ 'selectedServer' ], [ 'selectServer' ]));
|
||||||
bottle.decorator('MenuLayout', withRouter);
|
bottle.decorator('MenuLayout', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
bottle.serviceFactory('AsideMenu', AsideMenu, 'DeleteServerButton');
|
||||||
|
|
|
@ -36,7 +36,7 @@ const connect: ConnectDecorator = (propsFromState: string[] | null, actionServic
|
||||||
provideAppServices(bottle, connect);
|
provideAppServices(bottle, connect);
|
||||||
provideCommonServices(bottle, connect, withRouter);
|
provideCommonServices(bottle, connect, withRouter);
|
||||||
provideApiServices(bottle);
|
provideApiServices(bottle);
|
||||||
provideShortUrlsServices(bottle, connect);
|
provideShortUrlsServices(bottle, connect, withRouter);
|
||||||
provideServersServices(bottle, connect, withRouter);
|
provideServersServices(bottle, connect, withRouter);
|
||||||
provideTagsServices(bottle, connect);
|
provideTagsServices(bottle, connect);
|
||||||
provideVisitsServices(bottle, connect);
|
provideVisitsServices(bottle, connect);
|
||||||
|
|
|
@ -27,7 +27,7 @@ export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder
|
||||||
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||||
|
|
||||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -71,7 +71,7 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
||||||
const domains = await listDomains();
|
const domains = await listDomains();
|
||||||
|
|
||||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -115,6 +115,16 @@ hr {
|
||||||
color: var(--text-color) !important;
|
color: var(--text-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-item--danger.dropdown-item--danger {
|
||||||
|
color: $dangerColor;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
color: $dangerColor !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.badge-main {
|
.badge-main {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
background-color: var(--brand-color);
|
background-color: var(--brand-color);
|
||||||
|
|
|
@ -8,9 +8,3 @@
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.create-server__csv-select {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: -9999px;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,30 +1,35 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
|
import { Button } from 'reactstrap';
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import NoMenuLayout from '../common/NoMenuLayout';
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
import { ServerForm } from './helpers/ServerForm';
|
import { ServerForm } from './helpers/ServerForm';
|
||||||
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
import { ServerData, ServerWithId } from './data';
|
import { ServerData, ServersMap, ServerWithId } from './data';
|
||||||
import './CreateServer.scss';
|
import './CreateServer.scss';
|
||||||
|
|
||||||
const SHOW_IMPORT_MSG_TIME = 4000;
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
interface CreateServerProps extends RouterProps {
|
interface CreateServerProps extends RouterProps {
|
||||||
createServer: (server: ServerWithId) => void;
|
createServer: (server: ServerWithId) => void;
|
||||||
|
servers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
||||||
|
<div className="mt-3">
|
||||||
<Result type={type}>
|
<Result type={type}>
|
||||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
</Result>
|
</Result>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagTimeout: StateFlagTimeout) => (
|
||||||
{ createServer, history: { push } }: CreateServerProps,
|
{ servers, createServer, history: { push, goBack } }: CreateServerProps,
|
||||||
) => {
|
) => {
|
||||||
|
const hasServers = !!Object.keys(servers).length;
|
||||||
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ serversImported, setServersImported ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
|
@ -37,16 +42,14 @@ const CreateServer = (ImportServersBtn: FC<ImportServersBtnProps>, useStateFlagT
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={handleSubmit}>
|
||||||
<ImportServersBtn onImport={setServersImported} onImportError={setErrorImporting} />
|
{!hasServers &&
|
||||||
<button className="btn btn-outline-primary">Create server</button>
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />}
|
||||||
|
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
||||||
|
<Button outline color="primary" className="ml-2">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
{(serversImported || errorImporting) && (
|
|
||||||
<div className="mt-3">
|
|
||||||
{serversImported && <ImportResult type="success" />}
|
{serversImported && <ImportResult type="success" />}
|
||||||
{errorImporting && <ImportResult type="error" />}
|
{errorImporting && <ImportResult type="error" />}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { FC } from 'react';
|
||||||
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||||
import { RouterProps } from 'react-router';
|
import { RouterProps } from 'react-router';
|
||||||
import { ServerWithId } from './data';
|
import { ServerWithId } from './data';
|
||||||
|
@ -6,17 +7,20 @@ export interface DeleteServerModalProps {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
redirectHome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
interface DeleteServerModalConnectProps extends DeleteServerModalProps, RouterProps {
|
||||||
deleteServer: (server: ServerWithId) => void;
|
deleteServer: (server: ServerWithId) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeleteServerModal = ({ server, toggle, isOpen, deleteServer, history }: DeleteServerModalConnectProps) => {
|
const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
||||||
|
{ server, toggle, isOpen, deleteServer, history, redirectHome = true },
|
||||||
|
) => {
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
deleteServer(server);
|
deleteServer(server);
|
||||||
toggle();
|
toggle();
|
||||||
history.push('/');
|
redirectHome && history.push('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -10,7 +10,7 @@ interface EditServerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProps>((
|
||||||
{ editServer, selectedServer, history: { push, goBack } },
|
{ editServer, selectedServer, history: { goBack } },
|
||||||
) => {
|
) => {
|
||||||
if (!isServerWithId(selectedServer)) {
|
if (!isServerWithId(selectedServer)) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -18,7 +18,7 @@ export const EditServer = (ServerError: FC) => withSelectedServer<EditServerProp
|
||||||
|
|
||||||
const handleSubmit = (serverData: ServerData) => {
|
const handleSubmit = (serverData: ServerData) => {
|
||||||
editServer(selectedServer.id, serverData);
|
editServer(selectedServer.id, serverData);
|
||||||
push(`/server/${selectedServer.id}`);
|
goBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
86
src/servers/ManageServers.tsx
Normal file
86
src/servers/ManageServers.tsx
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { Button, Row } from 'reactstrap';
|
||||||
|
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import NoMenuLayout from '../common/NoMenuLayout';
|
||||||
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
|
import SearchField from '../utils/SearchField';
|
||||||
|
import { Result } from '../utils/Result';
|
||||||
|
import { StateFlagTimeout } from '../utils/helpers/hooks';
|
||||||
|
import { ImportServersBtnProps } from './helpers/ImportServersBtn';
|
||||||
|
import { ServersMap } from './data';
|
||||||
|
import { ManageServersRowProps } from './ManageServersRow';
|
||||||
|
import ServersExporter from './services/ServersExporter';
|
||||||
|
|
||||||
|
interface ManageServersProps {
|
||||||
|
servers: ServersMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHOW_IMPORT_MSG_TIME = 4000;
|
||||||
|
|
||||||
|
export const ManageServers = (
|
||||||
|
serversExporter: ServersExporter,
|
||||||
|
ImportServersBtn: FC<ImportServersBtnProps>,
|
||||||
|
useStateFlagTimeout: StateFlagTimeout,
|
||||||
|
ManageServersRow: FC<ManageServersRowProps>,
|
||||||
|
): FC<ManageServersProps> => ({ servers }) => {
|
||||||
|
const allServers = Object.values(servers);
|
||||||
|
const [ serversList, setServersList ] = useState(allServers);
|
||||||
|
const filterServers = (searchTerm: string) => setServersList(
|
||||||
|
allServers.filter(({ name, url }) => `${name} ${url}`.match(searchTerm)),
|
||||||
|
);
|
||||||
|
const hasAutoConnect = serversList.some(({ autoConnect }) => !!autoConnect);
|
||||||
|
const [ errorImporting, setErrorImporting ] = useStateFlagTimeout(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServersList(Object.values(servers));
|
||||||
|
}, [ servers ]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoMenuLayout>
|
||||||
|
<SearchField className="mb-3" onChange={filterServers} />
|
||||||
|
|
||||||
|
<Row className="mb-3">
|
||||||
|
<div className="col-md-6 d-flex d-md-block mb-2 mb-md-0">
|
||||||
|
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
|
{allServers.length > 0 && (
|
||||||
|
<Button outline className="ml-2 flex-fill" onClick={async () => serversExporter.exportServers()}>
|
||||||
|
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 text-md-right d-flex d-md-block">
|
||||||
|
<Button outline color="primary" className="flex-fill" tag={Link} to="/server/create">
|
||||||
|
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<SimpleCard>
|
||||||
|
<table className="table table-hover mb-0">
|
||||||
|
<thead className="responsive-table__header">
|
||||||
|
<tr>
|
||||||
|
{hasAutoConnect && <th style={{ width: '50px' }} />}
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Base URL</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!serversList.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
||||||
|
{serversList.map((server) =>
|
||||||
|
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />)
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</SimpleCard>
|
||||||
|
|
||||||
|
{errorImporting && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</NoMenuLayout>
|
||||||
|
);
|
||||||
|
};
|
38
src/servers/ManageServersRow.tsx
Normal file
38
src/servers/ManageServersRow.tsx
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
import { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
|
||||||
|
|
||||||
|
export interface ManageServersRowProps {
|
||||||
|
server: ServerWithId;
|
||||||
|
hasAutoConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManageServersRow = (
|
||||||
|
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>,
|
||||||
|
): FC<ManageServersRowProps> => ({ server, hasAutoConnect }) => (
|
||||||
|
<tr className="responsive-table__row">
|
||||||
|
{hasAutoConnect && (
|
||||||
|
<td className="responsive-table__cell" data-th="Auto-connect">
|
||||||
|
{server.autoConnect && (
|
||||||
|
<>
|
||||||
|
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
||||||
|
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||||
|
Auto-connect to this server
|
||||||
|
</UncontrolledTooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<th className="responsive-table__cell" data-th="Name">
|
||||||
|
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||||
|
</th>
|
||||||
|
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
||||||
|
<td className="responsive-table__cell text-right">
|
||||||
|
<ManageServersRowDropdown server={server} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
53
src/servers/ManageServersRowDropdown.tsx
Normal file
53
src/servers/ManageServersRowDropdown.tsx
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { FC } from 'react';
|
||||||
|
import { DropdownItem } from 'reactstrap';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faBan as toggleOffIcon,
|
||||||
|
faEdit as editIcon,
|
||||||
|
faMinusCircle as deleteIcon,
|
||||||
|
faPlug as connectIcon,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { faCircle as toggleOnIcon } from '@fortawesome/free-regular-svg-icons';
|
||||||
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
|
import { DeleteServerModalProps } from './DeleteServerModal';
|
||||||
|
import { ServerWithId } from './data';
|
||||||
|
|
||||||
|
export interface ManageServersRowDropdownProps {
|
||||||
|
server: ServerWithId;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManageServersRowDropdownConnectProps extends ManageServersRowDropdownProps {
|
||||||
|
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManageServersRowDropdown = (
|
||||||
|
DeleteServerModal: FC<DeleteServerModalProps>,
|
||||||
|
): FC<ManageServersRowDropdownConnectProps> => ({ server, setAutoConnect }) => {
|
||||||
|
const [ isMenuOpen, toggleMenu ] = useToggle();
|
||||||
|
const [ isModalOpen,, showModal, hideModal ] = useToggle();
|
||||||
|
const serverUrl = `/server/${server.id}`;
|
||||||
|
const { autoConnect: isAutoConnect } = server;
|
||||||
|
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownBtnMenu isOpen={isMenuOpen} toggle={toggleMenu}>
|
||||||
|
<DropdownItem tag={Link} to={serverUrl}>
|
||||||
|
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||||
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => setAutoConnect(server, !server.autoConnect)}>
|
||||||
|
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
||||||
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||||
|
</DropdownItem>
|
||||||
|
|
||||||
|
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
||||||
|
</DropdownBtnMenu>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,7 +1,6 @@
|
||||||
import { FC, useEffect } from 'react';
|
import { FC, useEffect } from 'react';
|
||||||
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
import { Card, CardBody, CardHeader, CardText, CardTitle, Row } from 'reactstrap';
|
||||||
import { Link, useHistory } from 'react-router-dom';
|
import { Link, useHistory } from 'react-router-dom';
|
||||||
import { ShortUrlsListParams } from '../short-urls/reducers/shortUrlsListParams';
|
|
||||||
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from '../short-urls/reducers/shortUrlsList';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { TagsList } from '../tags/reducers/tagsList';
|
import { TagsList } from '../tags/reducers/tagsList';
|
||||||
|
@ -11,12 +10,13 @@ import { CreateShortUrlProps } from '../short-urls/CreateShortUrl';
|
||||||
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
import { VisitsOverview } from '../visits/reducers/visitsOverview';
|
||||||
import { Versions } from '../utils/helpers/version';
|
import { Versions } from '../utils/helpers/version';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { isServerWithId, SelectedServer } from './data';
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
|
import { getServerId, SelectedServer } from './data';
|
||||||
import './Overview.scss';
|
import './Overview.scss';
|
||||||
|
|
||||||
interface OverviewConnectProps {
|
interface OverviewConnectProps {
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShlinkShortUrlsListParams) => void;
|
||||||
listTags: Function;
|
listTags: Function;
|
||||||
tagsList: TagsList;
|
tagsList: TagsList;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
@ -40,7 +40,7 @@ export const Overview = (
|
||||||
const { loading, shortUrls } = shortUrlsList;
|
const { loading, shortUrls } = shortUrlsList;
|
||||||
const { loading: loadingTags } = tagsList;
|
const { loading: loadingTags } = tagsList;
|
||||||
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
const { loading: loadingVisits, visitsCount, orphanVisitsCount } = visitsOverview;
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const serverId = getServerId(selectedServer);
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -107,7 +107,7 @@ export const Overview = (
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
className="mb-0"
|
className="mb-0"
|
||||||
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tag=${tag}`)}
|
onTagClick={(tag) => history.push(`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag)}`)}
|
||||||
/>
|
/>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
@ -1,45 +1,37 @@
|
||||||
import { isEmpty, values } from 'ramda';
|
import { isEmpty, values } from 'ramda';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { faPlus as plusIcon, faFileDownload as exportIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ServersExporter from './services/ServersExporter';
|
import { getServerId, SelectedServer, ServersMap } from './data';
|
||||||
import { isServerWithId, SelectedServer, ServersMap } from './data';
|
|
||||||
|
|
||||||
export interface ServersDropdownProps {
|
export interface ServersDropdownProps {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServersDropdown = (serversExporter: ServersExporter) => ({ servers, selectedServer }: ServersDropdownProps) => {
|
const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = values(servers);
|
const serversList = values(servers);
|
||||||
const createServerItem = (
|
|
||||||
|
const renderServers = () => {
|
||||||
|
if (isEmpty(serversList)) {
|
||||||
|
return (
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
<DropdownItem tag={Link} to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
<FontAwesomeIcon icon={plusIcon} /> <span className="ml-1">Add a server</span>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderServers = () => {
|
|
||||||
if (isEmpty(serversList)) {
|
|
||||||
return createServerItem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{serversList.map(({ name, id }) => (
|
{serversList.map(({ name, id }) => (
|
||||||
<DropdownItem
|
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
|
||||||
key={id}
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${id}`}
|
|
||||||
active={isServerWithId(selectedServer) && selectedServer.id === id}
|
|
||||||
>
|
|
||||||
{name}
|
{name}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
{createServerItem}
|
<DropdownItem tag={Link} to="/manage-servers">
|
||||||
<DropdownItem className="servers-dropdown__export-item" onClick={async () => serversExporter.exportServers()}>
|
<FontAwesomeIcon icon={serverIcon} /> <span className="ml-1">Manage servers</span>
|
||||||
<FontAwesomeIcon icon={exportIcon} /> <span className="ml-1">Export servers</span>
|
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,6 +8,7 @@ export interface ServerData {
|
||||||
|
|
||||||
export interface ServerWithId extends ServerData {
|
export interface ServerWithId extends ServerData {
|
||||||
id: string;
|
id: string;
|
||||||
|
autoConnect?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReachableServer extends ServerWithId {
|
export interface ReachableServer extends ServerWithId {
|
||||||
|
|
5
src/servers/helpers/ImportServersBtn.scss
Normal file
5
src/servers/helpers/ImportServersBtn.scss
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
.import-servers-btn__csv-select {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
|
@ -1,13 +1,19 @@
|
||||||
import { useRef, RefObject, ChangeEvent, MutableRefObject } from 'react';
|
import { useRef, RefObject, ChangeEvent, MutableRefObject, FC } from 'react';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||||
|
import { pipe } from 'ramda';
|
||||||
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import ServersImporter from '../services/ServersImporter';
|
import ServersImporter from '../services/ServersImporter';
|
||||||
import { ServerData } from '../data';
|
import { ServerData } from '../data';
|
||||||
|
import './ImportServersBtn.scss';
|
||||||
|
|
||||||
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
type Ref<T> = RefObject<T> | MutableRefObject<T>;
|
||||||
|
|
||||||
export interface ImportServersBtnProps {
|
export interface ImportServersBtnProps {
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: (error: Error) => void;
|
onImportError?: (error: Error) => void;
|
||||||
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
|
@ -15,17 +21,19 @@ interface ImportServersBtnConnectProps extends ImportServersBtnProps {
|
||||||
fileRef: Ref<HTMLInputElement>;
|
fileRef: Ref<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
const ImportServersBtn = ({ importServersFromFile }: ServersImporter): FC<ImportServersBtnConnectProps> => ({
|
||||||
createServers,
|
createServers,
|
||||||
fileRef,
|
fileRef,
|
||||||
|
children,
|
||||||
onImport = () => {},
|
onImport = () => {},
|
||||||
onImportError = () => {},
|
onImportError = () => {},
|
||||||
}: ImportServersBtnConnectProps) => {
|
tooltipPlacement = 'bottom',
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
const ref = fileRef ?? useRef<HTMLInputElement>();
|
const ref = fileRef ?? useRef<HTMLInputElement>();
|
||||||
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
const onChange = async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
importServersFromFile(target.files?.[0])
|
importServersFromFile(target.files?.[0])
|
||||||
.then(createServers)
|
.then(pipe(createServers, onImport))
|
||||||
.then(onImport)
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset input after processing file
|
// Reset input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null;
|
||||||
|
@ -34,19 +42,14 @@ const ImportServersBtn = ({ importServersFromFile }: ServersImporter) => ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||||
type="button"
|
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||||
className="btn btn-outline-secondary mr-2"
|
</Button>
|
||||||
id="importBtn"
|
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||||
onClick={() => ref.current?.click()}
|
|
||||||
>
|
|
||||||
Import from file
|
|
||||||
</button>
|
|
||||||
<UncontrolledTooltip placement="top" target="importBtn">
|
|
||||||
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
You can create servers by importing a CSV file with columns <b>name</b>, <b>apiKey</b> and <b>url</b>.
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
|
|
||||||
<input type="file" accept="text/csv" className="create-server__csv-select" ref={ref} onChange={onChange} />
|
<input type="file" accept="text/csv" className="import-servers-btn__csv-select" ref={ref} onChange={onChange} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -31,7 +31,7 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="mb-3" title={title}>
|
||||||
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||||
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||||
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
|
<FormGroup value={apiKey} onChange={setApiKey}>API key</FormGroup>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-right">{children}</div>
|
<div className="text-right">{children}</div>
|
||||||
|
|
|
@ -2,24 +2,17 @@ import { pipe, prop } from 'ramda';
|
||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { Dispatch } from 'redux';
|
import { Dispatch } from 'redux';
|
||||||
import { homepage } from '../../../package.json';
|
import { homepage } from '../../../package.json';
|
||||||
import { ServerData } from '../data';
|
import { hasServerData, ServerData } from '../data';
|
||||||
import { createServers } from './servers';
|
import { createServers } from './servers';
|
||||||
|
|
||||||
const responseToServersList = pipe(
|
const responseToServersList = pipe(
|
||||||
prop<any, any>('data'),
|
prop<any, any>('data'),
|
||||||
(data: any): ServerData[] => {
|
(data: any): ServerData[] => Array.isArray(data) ? data.filter(hasServerData) : [],
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
throw new Error('Value is not an array');
|
|
||||||
}
|
|
||||||
|
|
||||||
return data as ServerData[];
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
export const fetchServers = ({ get }: AxiosInstance) => () => async (dispatch: Dispatch) => {
|
||||||
const remoteList = await get(`${homepage}/servers.json`)
|
const resp = await get(`${homepage}/servers.json`);
|
||||||
.then(responseToServersList)
|
const remoteList = responseToServersList(resp);
|
||||||
.catch(() => []);
|
|
||||||
|
|
||||||
dispatch(createServers(remoteList));
|
dispatch(createServers(remoteList));
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { assoc, dissoc, map, pipe, reduce } 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 { Action } from 'redux';
|
||||||
import { ServerData, ServersMap, ServerWithId } from '../data';
|
import { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
|
@ -8,12 +8,22 @@ import { buildReducer } from '../../utils/helpers/redux';
|
||||||
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
export const EDIT_SERVER = 'shlink/servers/EDIT_SERVER';
|
||||||
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
export const DELETE_SERVER = 'shlink/servers/DELETE_SERVER';
|
||||||
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
export const CREATE_SERVERS = 'shlink/servers/CREATE_SERVERS';
|
||||||
|
export const SET_AUTO_CONNECT = 'shlink/servers/SET_AUTO_CONNECT';
|
||||||
/* eslint-enable padding-line-between-statements */
|
/* eslint-enable padding-line-between-statements */
|
||||||
|
|
||||||
export interface CreateServersAction extends Action<string> {
|
export interface CreateServersAction extends Action<string> {
|
||||||
newServers: ServersMap;
|
newServers: ServersMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DeleteServerAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetAutoConnectAction extends Action<string> {
|
||||||
|
serverId: string;
|
||||||
|
autoConnect: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const initialState: ServersMap = {};
|
const initialState: ServersMap = {};
|
||||||
|
|
||||||
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
|
@ -24,12 +34,28 @@ const serverWithId = (server: ServerWithId | ServerData): ServerWithId => {
|
||||||
return assoc('id', uuid(), server);
|
return assoc('id', uuid(), server);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default buildReducer<ServersMap, CreateServersAction>({
|
export default buildReducer<ServersMap, CreateServersAction & DeleteServerAction & SetAutoConnectAction>({
|
||||||
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
[CREATE_SERVERS]: (state, { newServers }) => ({ ...state, ...newServers }),
|
||||||
[DELETE_SERVER]: (state, { serverId }: any) => dissoc(serverId, state),
|
[DELETE_SERVER]: (state, { serverId }) => dissoc(serverId, state),
|
||||||
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
[EDIT_SERVER]: (state, { serverId, serverData }: any) => !state[serverId]
|
||||||
? state
|
? state
|
||||||
: assoc(serverId, { ...state[serverId], ...serverData }, 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);
|
}, 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), {});
|
||||||
|
@ -48,4 +74,10 @@ export const editServer = (serverId: string, serverData: Partial<ServerData>) =>
|
||||||
serverData,
|
serverData,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const deleteServer = ({ id }: ServerWithId) => ({ type: DELETE_SERVER, serverId: id });
|
export const deleteServer = ({ id }: ServerWithId): DeleteServerAction => ({ type: DELETE_SERVER, serverId: id });
|
||||||
|
|
||||||
|
export const setAutoConnect = ({ id }: ServerWithId, autoConnect: boolean): SetAutoConnectAction => ({
|
||||||
|
type: SET_AUTO_CONNECT,
|
||||||
|
serverId: id,
|
||||||
|
autoConnect,
|
||||||
|
});
|
||||||
|
|
|
@ -7,26 +7,44 @@ 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 { resetSelectedServer, selectServer } from '../reducers/selectedServer';
|
||||||
import { createServer, createServers, deleteServer, editServer } from '../reducers/servers';
|
import { createServer, createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
|
||||||
import { fetchServers } from '../reducers/remoteServers';
|
import { fetchServers } from '../reducers/remoteServers';
|
||||||
import ForServerVersion from '../helpers/ForServerVersion';
|
import ForServerVersion from '../helpers/ForServerVersion';
|
||||||
import { ServerError } from '../helpers/ServerError';
|
import { ServerError } from '../helpers/ServerError';
|
||||||
import { ConnectDecorator } from '../../container/types';
|
import { ConnectDecorator } from '../../container/types';
|
||||||
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
|
||||||
import { Overview } from '../Overview';
|
import { Overview } from '../Overview';
|
||||||
|
import { ManageServers } from '../ManageServers';
|
||||||
|
import { ManageServersRow } from '../ManageServersRow';
|
||||||
|
import { ManageServersRowDropdown } from '../ManageServersRowDropdown';
|
||||||
import ServersImporter from './ServersImporter';
|
import ServersImporter from './ServersImporter';
|
||||||
import ServersExporter from './ServersExporter';
|
import ServersExporter from './ServersExporter';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
|
bottle.serviceFactory(
|
||||||
|
'ManageServers',
|
||||||
|
ManageServers,
|
||||||
|
'ServersExporter',
|
||||||
|
'ImportServersBtn',
|
||||||
|
'useStateFlagTimeout',
|
||||||
|
'ManageServersRow',
|
||||||
|
);
|
||||||
|
bottle.decorator('ManageServers', connect([ 'servers' ]));
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageServersRow', ManageServersRow, 'ManageServersRowDropdown');
|
||||||
|
|
||||||
|
bottle.serviceFactory('ManageServersRowDropdown', ManageServersRowDropdown, 'DeleteServerModal');
|
||||||
|
bottle.decorator('ManageServersRowDropdown', connect(null, [ 'setAutoConnect' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
bottle.serviceFactory('CreateServer', CreateServer, 'ImportServersBtn', 'useStateFlagTimeout');
|
||||||
bottle.decorator('CreateServer', withoutSelectedServer);
|
bottle.decorator('CreateServer', withoutSelectedServer);
|
||||||
bottle.decorator('CreateServer', connect([ 'selectedServer' ], [ 'createServer', 'resetSelectedServer' ]));
|
bottle.decorator('CreateServer', connect([ 'selectedServer', 'servers' ], [ 'createServer', 'resetSelectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
bottle.serviceFactory('EditServer', EditServer, 'ServerError');
|
||||||
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
bottle.decorator('EditServer', connect([ 'selectedServer' ], [ 'editServer', 'selectServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('ServersDropdown', ServersDropdown, 'ServersExporter');
|
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
|
||||||
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
bottle.decorator('ServersDropdown', connect([ 'servers', 'selectedServer' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
|
||||||
|
@ -62,6 +80,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
||||||
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('fetchServers', fetchServers, 'axios');
|
bottle.serviceFactory('fetchServers', fetchServers, 'axios');
|
||||||
|
|
||||||
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
|
||||||
|
|
|
@ -18,7 +18,7 @@ const RealTimeUpdates = (
|
||||||
<SimpleCard title="Real-time updates" className="h-100">
|
<SimpleCard title="Real-time updates" className="h-100">
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
<ToggleSwitch checked={realTimeUpdates.enabled} onChange={toggleRealTimeUpdates}>
|
||||||
Enable or disable real-time updates, when using Shlink v2.2.0 or newer.
|
Enable or disable real-time updates.
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
Real-time updates are currently being <b>{realTimeUpdates.enabled ? 'processed' : 'ignored'}</b>.
|
||||||
</small>
|
</small>
|
||||||
|
|
|
@ -14,8 +14,8 @@ const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): s
|
||||||
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
||||||
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
||||||
tagFilteringMode === 'includes'
|
tagFilteringMode === 'includes'
|
||||||
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
|
? <>The list of suggested tags will contain those <b>including</b> provided input.</>
|
||||||
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
|
: <>The list of suggested tags will contain those <b>starting with</b> provided input.</>;
|
||||||
|
|
||||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||||
|
@ -24,19 +24,31 @@ export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShort
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleCard title="Short URLs creation" className="h-100">
|
<SimpleCard title="Short URLs form" className="h-100">
|
||||||
<FormGroup>
|
<FormGroup>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={shortUrlCreation.validateUrls ?? false}
|
checked={shortUrlCreation.validateUrls ?? false}
|
||||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||||
>
|
>
|
||||||
By default, request validation on long URLs when creating new short URLs.
|
Request validation on long URLs when creating new short URLs.
|
||||||
<small className="form-text text-muted">
|
<small className="form-text text-muted">
|
||||||
The initial state of the <b>Validate URL</b> checkbox will
|
The initial state of the <b>Validate URL</b> checkbox will
|
||||||
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||||
</small>
|
</small>
|
||||||
</ToggleSwitch>
|
</ToggleSwitch>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
<FormGroup>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={shortUrlCreation.forwardQuery ?? true}
|
||||||
|
onChange={(forwardQuery) => setShortUrlCreationSettings({ ...shortUrlCreation, forwardQuery })}
|
||||||
|
>
|
||||||
|
Make all new short URLs forward their query params to the long URL.
|
||||||
|
<small className="form-text text-muted">
|
||||||
|
The initial state of the <b>Forward query params on redirect</b> checkbox will
|
||||||
|
be <b>{shortUrlCreation.forwardQuery ?? true ? 'checked' : 'unchecked'}</b>.
|
||||||
|
</small>
|
||||||
|
</ToggleSwitch>
|
||||||
|
</FormGroup>
|
||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
<label>Tag suggestions search mode:</label>
|
<label>Tag suggestions search mode:</label>
|
||||||
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const Visits: FC<VisitsProps> = ({ settings, setVisitsSettings }) => (
|
||||||
<FormGroup className="mb-0">
|
<FormGroup className="mb-0">
|
||||||
<label>Default interval to load on visits sections:</label>
|
<label>Default interval to load on visits sections:</label>
|
||||||
<DateIntervalSelector
|
<DateIntervalSelector
|
||||||
|
allText="All visits"
|
||||||
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
active={settings.visits?.defaultInterval ?? 'last30Days'}
|
||||||
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
onChange={(defaultInterval) => setVisitsSettings({ defaultInterval })}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -22,6 +22,7 @@ export type TagFilteringMode = 'startsWith' | 'includes';
|
||||||
export interface ShortUrlCreationSettings {
|
export interface ShortUrlCreationSettings {
|
||||||
validateUrls: boolean;
|
validateUrls: boolean;
|
||||||
tagFilteringMode?: TagFilteringMode;
|
tagFilteringMode?: TagFilteringMode;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TagsMode = 'cards' | 'list';
|
export type TagsMode = 'cards' | 'list';
|
||||||
|
|
|
@ -30,6 +30,7 @@ const getInitialState = (settings?: ShortUrlCreationSettings): ShortUrlData => (
|
||||||
maxVisits: undefined,
|
maxVisits: undefined,
|
||||||
findIfExists: false,
|
findIfExists: false,
|
||||||
validateUrl: settings?.validateUrls ?? false,
|
validateUrl: settings?.validateUrls ?? false,
|
||||||
|
forwardQuery: settings?.forwardQuery ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
|
const CreateShortUrl = (ShortUrlForm: FC<ShortUrlFormProps>, CreateShortUrlResult: FC<CreateShortUrlResultProps>) => ({
|
||||||
|
|
|
@ -42,6 +42,7 @@ const getInitialState = (shortUrl?: ShortUrl, settings?: ShortUrlCreationSetting
|
||||||
validUntil: shortUrl.meta.validUntil ?? undefined,
|
validUntil: shortUrl.meta.validUntil ?? undefined,
|
||||||
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
maxVisits: shortUrl.meta.maxVisits ?? undefined,
|
||||||
crawlable: shortUrl.crawlable,
|
crawlable: shortUrl.crawlable,
|
||||||
|
forwardQuery: shortUrl.forwardQuery,
|
||||||
validateUrl,
|
validateUrl,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,15 +1,24 @@
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
import {
|
||||||
|
pageIsEllipsis,
|
||||||
|
keyForPage,
|
||||||
|
progressivePagination,
|
||||||
|
prettifyPageNumber,
|
||||||
|
NumberOrEllipsis,
|
||||||
|
} from '../utils/helpers/pagination';
|
||||||
import { ShlinkPaginator } from '../api/types';
|
import { ShlinkPaginator } from '../api/types';
|
||||||
|
|
||||||
interface PaginatorProps {
|
interface PaginatorProps {
|
||||||
paginator?: ShlinkPaginator;
|
paginator?: ShlinkPaginator;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
currentQueryString?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
const Paginator = ({ paginator, serverId, currentQueryString = '' }: PaginatorProps) => {
|
||||||
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
const { currentPage = 0, pagesCount = 0 } = paginator ?? {};
|
||||||
|
const urlForPage = (pageNumber: NumberOrEllipsis) =>
|
||||||
|
`/server/${serverId}/list-short-urls/${pageNumber}${currentQueryString}`;
|
||||||
|
|
||||||
if (pagesCount <= 1) {
|
if (pagesCount <= 1) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -22,10 +31,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
disabled={pageIsEllipsis(pageNumber)}
|
disabled={pageIsEllipsis(pageNumber)}
|
||||||
active={currentPage === pageNumber}
|
active={currentPage === pageNumber}
|
||||||
>
|
>
|
||||||
<PaginationLink
|
<PaginationLink tag={Link} to={urlForPage(pageNumber)}>
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${pageNumber}`}
|
|
||||||
>
|
|
||||||
{prettifyPageNumber(pageNumber)}
|
{prettifyPageNumber(pageNumber)}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
|
@ -34,19 +40,11 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
||||||
return (
|
return (
|
||||||
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
<Pagination className="sticky-card-paginator" listClassName="flex-wrap justify-content-center mb-0">
|
||||||
<PaginationItem disabled={currentPage === 1}>
|
<PaginationItem disabled={currentPage === 1}>
|
||||||
<PaginationLink
|
<PaginationLink previous tag={Link} to={urlForPage(currentPage - 1)} />
|
||||||
previous
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage - 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{renderPages()}
|
{renderPages()}
|
||||||
<PaginationItem disabled={currentPage >= pagesCount}>
|
<PaginationItem disabled={currentPage >= pagesCount}>
|
||||||
<PaginationLink
|
<PaginationLink next tag={Link} to={urlForPage(currentPage + 1)} />
|
||||||
next
|
|
||||||
tag={Link}
|
|
||||||
to={`/server/${serverId}/list-short-urls/${currentPage + 1}`}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,35 +2,43 @@ import { faTags as tagsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import Tag from '../tags/helpers/Tag';
|
import Tag from '../tags/helpers/Tag';
|
||||||
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 ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange } from '../utils/dates/types';
|
||||||
import { ShortUrlsListParams } from './reducers/shortUrlsListParams';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
import './SearchBar.scss';
|
import './SearchBar.scss';
|
||||||
|
|
||||||
interface SearchBarProps {
|
export type SearchBarProps = RouteChildrenProps<ShortUrlListRouteParams>;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
|
||||||
shortUrlsListParams: ShortUrlsListParams;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
const dateOrNull = (date?: string) => date ? parseISO(date) : null;
|
||||||
|
|
||||||
const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrlsListParams }: SearchBarProps) => {
|
const SearchBar = (colorGenerator: ColorGenerator) => (props: SearchBarProps) => {
|
||||||
const selectedTags = shortUrlsListParams.tags ?? [];
|
const [{ search, tags, startDate, endDate }, toFirstPage ] = useShortUrlsQuery(props);
|
||||||
|
const selectedTags = tags?.split(',') ?? [];
|
||||||
const setDates = pipe(
|
const setDates = pipe(
|
||||||
({ startDate, endDate }: DateRange) => ({
|
({ startDate, endDate }: DateRange) => ({
|
||||||
startDate: formatIsoDate(startDate) ?? undefined,
|
startDate: formatIsoDate(startDate) ?? undefined,
|
||||||
endDate: formatIsoDate(endDate) ?? undefined,
|
endDate: formatIsoDate(endDate) ?? undefined,
|
||||||
}),
|
}),
|
||||||
(dates) => listShortUrls({ ...shortUrlsListParams, ...dates }),
|
toFirstPage,
|
||||||
|
);
|
||||||
|
const setSearch = pipe(
|
||||||
|
(searchTerm: string) => isEmpty(searchTerm) ? undefined : searchTerm,
|
||||||
|
(search) => toFirstPage({ search }),
|
||||||
|
);
|
||||||
|
const removeTag = pipe(
|
||||||
|
(tag: string) => selectedTags.filter((selectedTag) => selectedTag !== tag),
|
||||||
|
(tagsList) => tagsList.length === 0 ? undefined : tagsList.join(','),
|
||||||
|
(tags) => toFirstPage({ tags }),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="search-bar-container">
|
<div className="search-bar-container">
|
||||||
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
<SearchField initialValue={search} onChange={setSearch} />
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
@ -38,8 +46,8 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={{
|
||||||
startDate: dateOrNull(shortUrlsListParams.startDate),
|
startDate: dateOrNull(startDate),
|
||||||
endDate: dateOrNull(shortUrlsListParams.endDate),
|
endDate: dateOrNull(endDate),
|
||||||
}}
|
}}
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
|
@ -47,24 +55,12 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isEmpty(selectedTags) && (
|
{selectedTags.length > 0 && (
|
||||||
<h4 className="search-bar__selected-tag mt-3">
|
<h4 className="search-bar__selected-tag mt-3">
|
||||||
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
<FontAwesomeIcon icon={tagsIcon} className="search-bar__tags-icon" />
|
||||||
|
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) =>
|
||||||
<Tag
|
<Tag colorGenerator={colorGenerator} key={tag} text={tag} clearable onClose={() => removeTag(tag)} />)}
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
key={tag}
|
|
||||||
text={tag}
|
|
||||||
clearable
|
|
||||||
onClose={() => listShortUrls(
|
|
||||||
{
|
|
||||||
...shortUrlsListParams,
|
|
||||||
tags: selectedTags.filter((selectedTag) => selectedTag !== tag),
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</h4>
|
</h4>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { cond, isEmpty, pipe, replace, trim, T } from 'ramda';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||||
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features';
|
import { supportsCrawlableVisits, supportsForwardQuery, supportsShortUrlTitle } 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';
|
||||||
import Checkbox from '../utils/Checkbox';
|
import Checkbox from '../utils/Checkbox';
|
||||||
|
@ -33,6 +33,7 @@ export interface ShortUrlFormProps {
|
||||||
|
|
||||||
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
const normalizeTag = pipe(trim, replace(/ /g, '-'));
|
||||||
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
const toDate = (date?: string | Date): Date | undefined => typeof date === 'string' ? parseISO(date) : date;
|
||||||
|
const dynamicColClasses = (flag: boolean) => ({ 'col-sm-6': flag, 'col-sm-12': !flag });
|
||||||
|
|
||||||
export const ShortUrlForm = (
|
export const ShortUrlForm = (
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
|
@ -109,11 +110,11 @@ export const ShortUrlForm = (
|
||||||
|
|
||||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||||
const showCustomizeCard = supportsTitle || !isEdit;
|
const showCustomizeCard = supportsTitle || !isEdit;
|
||||||
const limitAccessCardClasses = classNames('mb-3', {
|
const limitAccessCardClasses = classNames('mb-3', dynamicColClasses(showCustomizeCard));
|
||||||
'col-sm-6': showCustomizeCard,
|
|
||||||
'col-sm-12': !showCustomizeCard,
|
|
||||||
});
|
|
||||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||||
|
const showForwardQueryControl = supportsForwardQuery(selectedServer);
|
||||||
|
const showBehaviorCard = showCrawlableControl || showForwardQueryControl;
|
||||||
|
const extraChecksCardClasses = classNames('mb-3', dynamicColClasses(showBehaviorCard));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="short-url-form" onSubmit={submit}>
|
<form className="short-url-form" onSubmit={submit}>
|
||||||
|
@ -165,7 +166,9 @@ export const ShortUrlForm = (
|
||||||
</div>
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<SimpleCard title="Extra checks" className="mb-3">
|
<Row>
|
||||||
|
<div className={extraChecksCardClasses}>
|
||||||
|
<SimpleCard title="Extra checks">
|
||||||
<ShortUrlFormCheckboxGroup
|
<ShortUrlFormCheckboxGroup
|
||||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||||
checked={shortUrlData.validateUrl}
|
checked={shortUrlData.validateUrl}
|
||||||
|
@ -173,15 +176,6 @@ export const ShortUrlForm = (
|
||||||
>
|
>
|
||||||
Validate URL
|
Validate URL
|
||||||
</ShortUrlFormCheckboxGroup>
|
</ShortUrlFormCheckboxGroup>
|
||||||
{showCrawlableControl && (
|
|
||||||
<ShortUrlFormCheckboxGroup
|
|
||||||
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
|
||||||
checked={shortUrlData.crawlable}
|
|
||||||
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
|
||||||
>
|
|
||||||
Make it crawlable
|
|
||||||
</ShortUrlFormCheckboxGroup>
|
|
||||||
)}
|
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<p>
|
<p>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
@ -196,6 +190,32 @@ export const ShortUrlForm = (
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
</div>
|
||||||
|
{showBehaviorCard && (
|
||||||
|
<div className="col-sm-6 mb-3">
|
||||||
|
<SimpleCard title="Configure behavior">
|
||||||
|
{showCrawlableControl && (
|
||||||
|
<ShortUrlFormCheckboxGroup
|
||||||
|
infoTooltip="This short URL will be included in the robots.txt for your Shlink instance, allowing web crawlers (like Google) to index it."
|
||||||
|
checked={shortUrlData.crawlable}
|
||||||
|
onChange={(crawlable) => setShortUrlData({ ...shortUrlData, crawlable })}
|
||||||
|
>
|
||||||
|
Make it crawlable
|
||||||
|
</ShortUrlFormCheckboxGroup>
|
||||||
|
)}
|
||||||
|
{showForwardQueryControl && (
|
||||||
|
<ShortUrlFormCheckboxGroup
|
||||||
|
infoTooltip="When this short URL is visited, any query params appended to it will be forwarded to the long URL."
|
||||||
|
checked={shortUrlData.forwardQuery}
|
||||||
|
onChange={(forwardQuery) => setShortUrlData({ ...shortUrlData, forwardQuery })}
|
||||||
|
>
|
||||||
|
Forward query params on redirect
|
||||||
|
</ShortUrlFormCheckboxGroup>
|
||||||
|
)}
|
||||||
|
</SimpleCard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -1,23 +0,0 @@
|
||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { ShortUrlsListProps } from './ShortUrlsList';
|
|
||||||
|
|
||||||
const ShortUrls = (SearchBar: FC, ShortUrlsList: FC<ShortUrlsListProps>) => (props: ShortUrlsListProps) => {
|
|
||||||
const { match } = props;
|
|
||||||
const { page = '1', serverId = '' } = match?.params ?? {};
|
|
||||||
const [ urlsListKey, setUrlsListKey ] = useState(`${serverId}_${page}`);
|
|
||||||
|
|
||||||
// Using a key on a component makes react to create a new instance every time the key changes
|
|
||||||
// Without it, pagination on the URL will not make the component to be refreshed
|
|
||||||
useEffect(() => {
|
|
||||||
setUrlsListKey(`${serverId}_${page}`);
|
|
||||||
}, [ serverId, page ]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="form-group"><SearchBar /></div>
|
|
||||||
<ShortUrlsList {...props} key={urlsListKey} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShortUrls;
|
|
|
@ -1,3 +0,0 @@
|
||||||
.short-urls-list__header-icon {
|
|
||||||
margin-left: .4rem;
|
|
||||||
}
|
|
|
@ -1,27 +1,21 @@
|
||||||
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
import { head, keys, pipe, values } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { head, keys, values } from 'ramda';
|
|
||||||
import { FC, useEffect, useState } from 'react';
|
|
||||||
import { RouteComponentProps } from 'react-router';
|
import { RouteComponentProps } from 'react-router';
|
||||||
import { Card } from 'reactstrap';
|
import { Card } from 'reactstrap';
|
||||||
import SortingDropdown from '../utils/SortingDropdown';
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
import { determineOrderDir, Order, OrderDir } from '../utils/helpers/ordering';
|
||||||
import { isReachableServer, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
|
import { ShlinkShortUrlsListParams } from '../api/types';
|
||||||
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
import { ShortUrlsList as ShortUrlsListState } from './reducers/shortUrlsList';
|
||||||
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
import { OrderableFields, ShortUrlsListParams, SORTABLE_FIELDS } from './reducers/shortUrlsListParams';
|
||||||
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
import { ShortUrlsTableProps } from './ShortUrlsTable';
|
||||||
import Paginator from './Paginator';
|
import Paginator from './Paginator';
|
||||||
import './ShortUrlsList.scss';
|
import { ShortUrlListRouteParams, useShortUrlsQuery } from './helpers/hooks';
|
||||||
|
|
||||||
interface RouteParams {
|
interface ShortUrlsListProps extends RouteComponentProps<ShortUrlListRouteParams> {
|
||||||
page: string;
|
|
||||||
serverId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
shortUrlsList: ShortUrlsListState;
|
shortUrlsList: ShortUrlsListState;
|
||||||
listShortUrls: (params: ShortUrlsListParams) => void;
|
listShortUrls: (params: ShortUrlsListParams) => void;
|
||||||
|
@ -29,73 +23,65 @@ export interface ShortUrlsListProps extends RouteComponentProps<RouteParams> {
|
||||||
resetShortUrlParams: () => void;
|
resetShortUrlParams: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercureHub(({
|
type ShortUrlsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
|
const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>, SearchBar: FC) => boundToMercureHub(({
|
||||||
listShortUrls,
|
listShortUrls,
|
||||||
resetShortUrlParams,
|
resetShortUrlParams,
|
||||||
shortUrlsListParams,
|
shortUrlsListParams,
|
||||||
match,
|
match,
|
||||||
location,
|
location,
|
||||||
|
history,
|
||||||
shortUrlsList,
|
shortUrlsList,
|
||||||
selectedServer,
|
selectedServer,
|
||||||
}: ShortUrlsListProps) => {
|
}: ShortUrlsListProps) => {
|
||||||
|
const serverId = getServerId(selectedServer);
|
||||||
const { orderBy } = shortUrlsListParams;
|
const { orderBy } = shortUrlsListParams;
|
||||||
const [ order, setOrder ] = useState<{ orderField?: OrderableFields; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<ShortUrlsOrder>({
|
||||||
orderField: orderBy && (head(keys(orderBy)) as OrderableFields),
|
field: orderBy && (head(keys(orderBy)) as OrderableFields),
|
||||||
orderDir: orderBy && head(values(orderBy)),
|
dir: orderBy && head(values(orderBy)),
|
||||||
});
|
});
|
||||||
|
const [{ tags, search, startDate, endDate }, toFirstPage ] = useShortUrlsQuery({ history, match, location });
|
||||||
|
const selectedTags = useMemo(() => tags?.split(',') ?? [], [ tags ]);
|
||||||
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
const { pagination } = shortUrlsList?.shortUrls ?? {};
|
||||||
const refreshList = (extraParams: ShortUrlsListParams) => listShortUrls({ ...shortUrlsListParams, ...extraParams });
|
|
||||||
const handleOrderBy = (orderField?: OrderableFields, orderDir?: OrderDir) => {
|
const refreshList = (extraParams: ShlinkShortUrlsListParams) => listShortUrls(
|
||||||
setOrder({ orderField, orderDir });
|
{ ...shortUrlsListParams, ...extraParams },
|
||||||
refreshList({ orderBy: orderField ? { [orderField]: orderDir } : undefined });
|
);
|
||||||
|
const handleOrderBy = (field?: OrderableFields, dir?: OrderDir) => {
|
||||||
|
setOrder({ field, dir });
|
||||||
|
refreshList({ orderBy: field ? { [field]: dir } : undefined });
|
||||||
};
|
};
|
||||||
const orderByColumn = (field: OrderableFields) => () =>
|
const orderByColumn = (field: OrderableFields) => () =>
|
||||||
handleOrderBy(field, determineOrderDir(field, order.orderField, order.orderDir));
|
handleOrderBy(field, determineOrderDir(field, order.field, order.dir));
|
||||||
const renderOrderIcon = (field: OrderableFields) => {
|
const renderOrderIcon = (field: OrderableFields) => <TableOrderIcon currentOrder={order} field={field} />;
|
||||||
if (order.orderField !== field) {
|
const addTag = pipe(
|
||||||
return null;
|
(newTag: string) => [ ...new Set([ ...selectedTags, newTag ]) ].join(','),
|
||||||
}
|
(tags) => toFirstPage({ tags }),
|
||||||
|
|
||||||
if (!order.orderDir) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={order.orderDir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="short-urls-list__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
|
useEffect(() => resetShortUrlParams, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const { tag } = parseQuery<{ tag?: string }>(location.search);
|
refreshList(
|
||||||
const tags = tag ? [ decodeURIComponent(tag) ] : shortUrlsListParams.tags;
|
{ page: match.params.page, searchTerm: search, tags: selectedTags, itemsPerPage: undefined, startDate, endDate },
|
||||||
|
);
|
||||||
refreshList({ page: match.params.page, tags, itemsPerPage: undefined });
|
}, [ match.params.page, search, selectedTags, startDate, endDate ]);
|
||||||
|
|
||||||
return resetShortUrlParams;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<div className="mb-3"><SearchBar /></div>
|
||||||
<div className="d-block d-lg-none mb-3">
|
<div className="d-block d-lg-none mb-3">
|
||||||
<SortingDropdown
|
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={handleOrderBy} />
|
||||||
items={SORTABLE_FIELDS}
|
|
||||||
orderField={order.orderField}
|
|
||||||
orderDir={order.orderDir}
|
|
||||||
onChange={handleOrderBy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Card body className="pb-1">
|
<Card body className="pb-1">
|
||||||
<ShortUrlsTable
|
<ShortUrlsTable
|
||||||
orderByColumn={orderByColumn}
|
|
||||||
renderOrderIcon={renderOrderIcon}
|
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
shortUrlsList={shortUrlsList}
|
shortUrlsList={shortUrlsList}
|
||||||
onTagClick={(tag) => refreshList({ tags: [ ...shortUrlsListParams.tags ?? [], tag ] })}
|
orderByColumn={orderByColumn}
|
||||||
|
renderOrderIcon={renderOrderIcon}
|
||||||
|
onTagClick={addTag}
|
||||||
/>
|
/>
|
||||||
<Paginator paginator={pagination} serverId={isReachableServer(selectedServer) ? selectedServer.id : ''} />
|
<Paginator paginator={pagination} serverId={serverId} currentQueryString={location.search} />
|
||||||
</Card>
|
</Card>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -35,7 +35,9 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="text-center table-danger">Something went wrong while loading short URLs :(</td>
|
<td colSpan={6} className="text-center table-danger text-dark">
|
||||||
|
Something went wrong while loading short URLs :(
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -63,34 +65,29 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
||||||
<thead className="responsive-table__header short-urls-table__header">
|
<thead className="responsive-table__header short-urls-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||||
Created at
|
Created at {renderOrderIcon?.('dateCreated')}
|
||||||
{renderOrderIcon?.('dateCreated')}
|
|
||||||
</th>
|
</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('shortCode')}>
|
||||||
Short URL
|
Short URL {renderOrderIcon?.('shortCode')}
|
||||||
{renderOrderIcon?.('shortCode')}
|
|
||||||
</th>
|
</th>
|
||||||
{!supportsTitle && (
|
{!supportsTitle && (
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
Long URL
|
Long URL {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</th>
|
</th>
|
||||||
) || (
|
) || (
|
||||||
<th className="short-urls-table__header-cell">
|
<th className="short-urls-table__header-cell">
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('title')}>
|
||||||
Title
|
Title {renderOrderIcon?.('title')}
|
||||||
{renderOrderIcon?.('title')}
|
|
||||||
</span>
|
</span>
|
||||||
/
|
/
|
||||||
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
<span className={actionableFieldClasses} onClick={orderByColumn?.('longUrl')}>
|
||||||
<span className="indivisible">Long URL</span>
|
<span className="indivisible">Long URL</span> {renderOrderIcon?.('longUrl')}
|
||||||
{renderOrderIcon?.('longUrl')}
|
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
<th className="short-urls-table__header-cell">Tags</th>
|
<th className="short-urls-table__header-cell">Tags</th>
|
||||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
<th className={orderableColumnsClasses} onClick={orderByColumn?.('visits')}>
|
||||||
<span className="indivisible">Visits{renderOrderIcon?.('visits')}</span>
|
<span className="indivisible">Visits {renderOrderIcon?.('visits')}</span>
|
||||||
</th>
|
</th>
|
||||||
<th className="short-urls-table__header-cell"> </th>
|
<th className="short-urls-table__header-cell"> </th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -9,6 +9,7 @@ export interface EditShortUrlData {
|
||||||
maxVisits?: number | null;
|
maxVisits?: number | null;
|
||||||
validateUrl?: boolean;
|
validateUrl?: boolean;
|
||||||
crawlable?: boolean;
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlData extends EditShortUrlData {
|
export interface ShortUrlData extends EditShortUrlData {
|
||||||
|
@ -30,6 +31,7 @@ export interface ShortUrl {
|
||||||
domain: string | null;
|
domain: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
crawlable?: boolean;
|
crawlable?: boolean;
|
||||||
|
forwardQuery?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShortUrlMeta {
|
export interface ShortUrlMeta {
|
||||||
|
|
|
@ -1,11 +0,0 @@
|
||||||
@import '../../utils/base';
|
|
||||||
|
|
||||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
|
||||||
color: $dangerColor;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:active,
|
|
||||||
&.active {
|
|
||||||
color: $dangerColor !important;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,6 @@ import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||||
import './ShortUrlsRowMenu.scss';
|
|
||||||
|
|
||||||
export interface ShortUrlsRowMenuProps {
|
export interface ShortUrlsRowMenuProps {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
|
@ -45,7 +44,7 @@ const ShortUrlsRowMenu = (
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
|
|
||||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
<DropdownItem className="dropdown-item--danger" onClick={toggleDelete}>
|
||||||
<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={toggleDelete} />
|
||||||
|
|
31
src/short-urls/helpers/hooks.ts
Normal file
31
src/short-urls/helpers/hooks.ts
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { isEmpty } from 'ramda';
|
||||||
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
|
||||||
|
type ServerIdRouteProps = RouteChildrenProps<{ serverId: string }>;
|
||||||
|
type ToFirstPage = (extra: Partial<ShortUrlsQuery>) => void;
|
||||||
|
|
||||||
|
export interface ShortUrlListRouteParams {
|
||||||
|
page: string;
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortUrlsQuery {
|
||||||
|
tags?: string;
|
||||||
|
search?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useShortUrlsQuery = ({ history, location, match }: ServerIdRouteProps): [ShortUrlsQuery, ToFirstPage] => {
|
||||||
|
const query = useMemo(() => parseQuery<ShortUrlsQuery>(location.search), [ location ]);
|
||||||
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsQuery>) => {
|
||||||
|
const evolvedQuery = stringifyQuery({ ...query, ...extra });
|
||||||
|
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||||
|
|
||||||
|
history.push(`/server/${match?.params.serverId}/list-short-urls/1${queryString}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [ query, toFirstPageWithExtra ];
|
||||||
|
};
|
|
@ -49,7 +49,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
const result = await createShortUrl(data);
|
const result = await createShortUrl(data);
|
||||||
|
|
||||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -48,7 +48,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
try {
|
try {
|
||||||
await deleteShortUrl(shortCode, domain);
|
await deleteShortUrl(shortCode, domain);
|
||||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -50,7 +50,7 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||||
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
) ?? await buildShlinkApiClient(getState).getShortUrl(shortCode, domain);
|
||||||
|
|
||||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -55,7 +55,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
]);
|
]);
|
||||||
|
|
||||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { CREATE_VISITS, CreateVisitsAction } from '../../visits/reducers/visitCr
|
||||||
import { buildReducer } from '../../utils/helpers/redux';
|
import { buildReducer } from '../../utils/helpers/redux';
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||||
import { ShlinkShortUrlsResponse } from '../../api/types';
|
import { ShlinkShortUrlsListParams, ShlinkShortUrlsResponse } from '../../api/types';
|
||||||
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
import { DeleteShortUrlAction, SHORT_URL_DELETED } from './shortUrlDeletion';
|
||||||
import { ShortUrlsListParams } from './shortUrlsListParams';
|
import { ShortUrlsListParams } from './shortUrlsListParams';
|
||||||
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
import { CREATE_SHORT_URL, CreateShortUrlAction } from './shortUrlCreation';
|
||||||
|
@ -101,7 +101,7 @@ export default buildReducer<ShortUrlsList, ListShortUrlsCombinedAction>({
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
export const listShortUrls = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
params: ShortUrlsListParams = {},
|
params: ShlinkShortUrlsListParams = {},
|
||||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||||
dispatch({ type: LIST_SHORT_URLS_START });
|
dispatch({ type: LIST_SHORT_URLS_START });
|
||||||
const { listShortUrls } = buildShlinkApiClient(getState);
|
const { listShortUrls } = buildShlinkApiClient(getState);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||||
import { OrderDir } from '../../utils/utils';
|
import { OrderDir } from '../../utils/helpers/ordering';
|
||||||
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
import { LIST_SHORT_URLS, ListShortUrlsAction } from './shortUrlsList';
|
||||||
|
|
||||||
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
export const RESET_SHORT_URL_PARAMS = 'shlink/shortUrlsListParams/RESET_SHORT_URL_PARAMS';
|
||||||
|
@ -14,14 +14,12 @@ export const SORTABLE_FIELDS = {
|
||||||
|
|
||||||
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||||
|
|
||||||
|
export type OrderBy = Partial<Record<OrderableFields, OrderDir>>;
|
||||||
|
|
||||||
export interface ShortUrlsListParams {
|
export interface ShortUrlsListParams {
|
||||||
page?: string;
|
page?: string;
|
||||||
itemsPerPage?: number;
|
itemsPerPage?: number;
|
||||||
tags?: string[];
|
orderBy?: OrderBy;
|
||||||
searchTerm?: string;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
orderBy?: Partial<Record<OrderableFields, OrderDir>>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ShortUrlsListParams = {
|
const initialState: ShortUrlsListParams = {
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import Bottle from 'bottlejs';
|
import Bottle, { Decorator } from 'bottlejs';
|
||||||
import ShortUrls from '../ShortUrls';
|
|
||||||
import SearchBar from '../SearchBar';
|
import SearchBar from '../SearchBar';
|
||||||
import ShortUrlsList from '../ShortUrlsList';
|
import ShortUrlsList from '../ShortUrlsList';
|
||||||
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
import ShortUrlsRow from '../helpers/ShortUrlsRow';
|
||||||
|
@ -19,14 +18,11 @@ import { ShortUrlForm } from '../ShortUrlForm';
|
||||||
import { EditShortUrl } from '../EditShortUrl';
|
import { EditShortUrl } from '../EditShortUrl';
|
||||||
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
import { getShortUrlDetail } from '../reducers/shortUrlDetail';
|
||||||
|
|
||||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||||
// Components
|
// Components
|
||||||
bottle.serviceFactory('ShortUrls', ShortUrls, 'SearchBar', 'ShortUrlsList');
|
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable', 'SearchBar');
|
||||||
bottle.decorator('ShortUrls', connect([ 'shortUrlsList' ]));
|
|
||||||
|
|
||||||
bottle.serviceFactory('ShortUrlsList', ShortUrlsList, 'ShortUrlsTable');
|
|
||||||
bottle.decorator('ShortUrlsList', connect(
|
bottle.decorator('ShortUrlsList', connect(
|
||||||
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo' ],
|
[ 'selectedServer', 'shortUrlsListParams', 'mercureInfo', 'shortUrlsList' ],
|
||||||
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
[ 'listShortUrls', 'resetShortUrlParams', 'createNewVisits', 'loadMercureInfo' ],
|
||||||
));
|
));
|
||||||
|
|
||||||
|
@ -56,7 +52,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
bottle.serviceFactory('SearchBar', SearchBar, 'ColorGenerator');
|
||||||
bottle.decorator('SearchBar', connect([ 'shortUrlsListParams' ], [ 'listShortUrls' ]));
|
bottle.decorator('SearchBar', withRouter);
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
bottle.serviceFactory('listShortUrls', listShortUrls, 'buildShlinkApiClient');
|
||||||
|
|
|
@ -6,14 +6,13 @@ import { Link } from 'react-router-dom';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
import { getServerId, SelectedServer } from '../servers/data';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import { TagModalProps, TagStats } from './data';
|
import { NormalizedTag, TagModalProps } from './data';
|
||||||
import './TagCard.scss';
|
import './TagCard.scss';
|
||||||
|
|
||||||
export interface TagCardProps {
|
export interface TagCardProps {
|
||||||
tag: string;
|
tag: NormalizedTag;
|
||||||
tagStats?: TagStats;
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
displayed: boolean;
|
displayed: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
@ -25,12 +24,12 @@ const TagCard = (
|
||||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
EditTagModal: FC<TagModalProps>,
|
EditTagModal: FC<TagModalProps>,
|
||||||
colorGenerator: ColorGenerator,
|
colorGenerator: ColorGenerator,
|
||||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
) => ({ tag, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
const [ hasTitle,, displayTitle ] = useToggle();
|
const [ hasTitle,, displayTitle ] = useToggle();
|
||||||
const titleRef = useRef<HTMLElement>();
|
const titleRef = useRef<HTMLElement>();
|
||||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
const serverId = getServerId(selectedServer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isTruncated(titleRef.current)) {
|
if (isTruncated(titleRef.current)) {
|
||||||
|
@ -49,39 +48,37 @@ const TagCard = (
|
||||||
</Button>
|
</Button>
|
||||||
<h5
|
<h5
|
||||||
className="tag-card__tag-title text-ellipsis"
|
className="tag-card__tag-title text-ellipsis"
|
||||||
title={hasTitle ? tag : undefined}
|
title={hasTitle ? tag.tag : undefined}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
titleRef.current = el ?? undefined;
|
titleRef.current = el ?? undefined;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} />
|
||||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
<span className="tag-card__tag-name" onClick={toggle}>{tag.tag}</span>
|
||||||
</h5>
|
</h5>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{tagStats && (
|
|
||||||
<Collapse isOpen={displayed}>
|
<Collapse isOpen={displayed}>
|
||||||
<CardBody className="tag-card__body">
|
<CardBody className="tag-card__body">
|
||||||
<Link
|
<Link
|
||||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}
|
to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}
|
||||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center mb-1"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faLink} className="mr-2" />Short URLs</span>
|
||||||
<b>{prettify(tagStats.shortUrlsCount)}</b>
|
<b>{prettify(tag.shortUrls)}</b>
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/server/${serverId}/tag/${tag}/visits`}
|
to={`/server/${serverId}/tag/${tag.tag}/visits`}
|
||||||
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
className="btn btn-outline-secondary btn-block d-flex justify-content-between align-items-center"
|
||||||
>
|
>
|
||||||
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
<span className="text-ellipsis"><FontAwesomeIcon icon={faEye} className="mr-2" />Visits</span>
|
||||||
<b>{prettify(tagStats.visitsCount)}</b>
|
<b>{prettify(tag.visits)}</b>
|
||||||
</Link>
|
</Link>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
)}
|
|
||||||
|
|
||||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,10 +7,10 @@ import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||||
const { ceil } = Math;
|
const { ceil } = Math;
|
||||||
const TAGS_GROUPS_AMOUNT = 4;
|
const TAGS_GROUPS_AMOUNT = 4;
|
||||||
|
|
||||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ sortedTags, selectedServer }) => {
|
||||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||||
const tagsCount = tagsList.filteredTags.length;
|
const tagsCount = sortedTags.length;
|
||||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), sortedTags);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row>
|
<Row>
|
||||||
|
@ -18,12 +18,11 @@ export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps>
|
||||||
<div key={index} className="col-md-6 col-xl-3">
|
<div key={index} className="col-md-6 col-xl-3">
|
||||||
{group.map((tag) => (
|
{group.map((tag) => (
|
||||||
<TagCard
|
<TagCard
|
||||||
key={tag}
|
key={tag.tag}
|
||||||
tag={tag}
|
tag={tag}
|
||||||
tagStats={tagsList.stats[tag]}
|
|
||||||
selectedServer={selectedServer}
|
selectedServer={selectedServer}
|
||||||
displayed={displayedTag === tag}
|
displayed={displayedTag === tag.tag}
|
||||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
toggle={() => setDisplayedTag(displayedTag !== tag.tag ? tag.tag : undefined)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { Row } from 'reactstrap';
|
import { Row } from 'reactstrap';
|
||||||
|
import { pipe } from 'ramda';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
|
@ -8,9 +9,13 @@ import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Topics } from '../mercure/helpers/Topics';
|
import { Topics } from '../mercure/helpers/Topics';
|
||||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||||
|
import { determineOrderDir, sortList } from '../utils/helpers/ordering';
|
||||||
|
import SortingDropdown from '../utils/SortingDropdown';
|
||||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
import { OrderableFields, SORTABLE_FIELDS, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||||
|
import { NormalizedTag } from './data';
|
||||||
|
import { TagsTableProps } from './TagsTable';
|
||||||
|
|
||||||
export interface TagsListProps {
|
export interface TagsListProps {
|
||||||
filterTags: (searchTerm: string) => void;
|
filterTags: (searchTerm: string) => void;
|
||||||
|
@ -20,10 +25,19 @@ export interface TagsListProps {
|
||||||
settings: Settings;
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsTableProps>) => boundToMercureHub((
|
||||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||||
) => {
|
) => {
|
||||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||||
|
const [ order, setOrder ] = useState<TagsOrder>({});
|
||||||
|
const resolveSortedTags = pipe(
|
||||||
|
() => tagsList.filteredTags.map((tag): NormalizedTag => ({
|
||||||
|
tag,
|
||||||
|
shortUrls: tagsList.stats[tag]?.shortUrlsCount ?? 0,
|
||||||
|
visits: tagsList.stats[tag]?.visitsCount ?? 0,
|
||||||
|
})),
|
||||||
|
(normalizedTags) => sortList<NormalizedTag>(normalizedTags, order),
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
forceListTags();
|
forceListTags();
|
||||||
|
@ -33,7 +47,6 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
|
||||||
return <Message loading />;
|
return <Message loading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
if (tagsList.error) {
|
if (tagsList.error) {
|
||||||
return (
|
return (
|
||||||
<Result type="error">
|
<Result type="error">
|
||||||
|
@ -42,22 +55,41 @@ const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListCh
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orderByColumn = (field: OrderableFields) => () => {
|
||||||
|
const dir = determineOrderDir(field, order.field, order.dir);
|
||||||
|
|
||||||
|
setOrder({ field: dir ? field : undefined, dir });
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
if (tagsList.filteredTags.length < 1) {
|
if (tagsList.filteredTags.length < 1) {
|
||||||
return <Message>No tags found</Message>;
|
return <Message>No tags found</Message>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortedTags = resolveSortedTags();
|
||||||
|
|
||||||
return mode === 'cards'
|
return mode === 'cards'
|
||||||
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
? <TagsCards sortedTags={sortedTags} selectedServer={selectedServer} />
|
||||||
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
: (
|
||||||
|
<TagsTable
|
||||||
|
sortedTags={sortedTags}
|
||||||
|
selectedServer={selectedServer}
|
||||||
|
currentOrder={order}
|
||||||
|
orderByColumn={orderByColumn}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchField className="mb-3" onChange={filterTags} />
|
<SearchField className="mb-3" onChange={filterTags} />
|
||||||
<Row className="mb-3">
|
<Row className="mb-3">
|
||||||
<div className="col-lg-6 offset-lg-6">
|
<div className="col-lg-6">
|
||||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-lg-6 mt-3 mt-lg-0">
|
||||||
|
<SortingDropdown items={SORTABLE_FIELDS} order={order} onChange={(field, dir) => setOrder({ field, dir })} />
|
||||||
|
</div>
|
||||||
</Row>
|
</Row>
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</>
|
</>
|
||||||
|
|
10
src/tags/TagsTable.scss
Normal file
10
src/tags/TagsTable.scss
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
@import '../utils/base';
|
||||||
|
@import '../utils/mixins/sticky-cell';
|
||||||
|
|
||||||
|
.tags-table__header-cell.tags-table__header-cell {
|
||||||
|
@include sticky-cell(false);
|
||||||
|
|
||||||
|
top: $headerHeight;
|
||||||
|
position: sticky;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
|
@ -2,22 +2,27 @@ import { FC, useEffect, useRef } from 'react';
|
||||||
import { splitEvery } from 'ramda';
|
import { splitEvery } from 'ramda';
|
||||||
import { RouteChildrenProps } from 'react-router';
|
import { RouteChildrenProps } from 'react-router';
|
||||||
import { SimpleCard } from '../utils/SimpleCard';
|
import { SimpleCard } from '../utils/SimpleCard';
|
||||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
|
||||||
import SimplePaginator from '../common/SimplePaginator';
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
import { useQueryState } from '../utils/helpers/hooks';
|
import { useQueryState } from '../utils/helpers/hooks';
|
||||||
import { parseQuery } from '../utils/helpers/query';
|
import { parseQuery } from '../utils/helpers/query';
|
||||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
|
import { OrderableFields, TagsListChildrenProps, TagsOrder } from './data/TagsListChildrenProps';
|
||||||
import { TagsTableRowProps } from './TagsTableRow';
|
import { TagsTableRowProps } from './TagsTableRow';
|
||||||
|
import './TagsTable.scss';
|
||||||
|
|
||||||
|
export interface TagsTableProps extends TagsListChildrenProps {
|
||||||
|
orderByColumn: (field: OrderableFields) => () => void;
|
||||||
|
currentOrder: TagsOrder;
|
||||||
|
}
|
||||||
|
|
||||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||||
|
|
||||||
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
export const TagsTable = (TagsTableRow: FC<TagsTableRowProps>) => (
|
||||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
{ sortedTags, selectedServer, location, orderByColumn, currentOrder }: TagsTableProps & RouteChildrenProps,
|
||||||
) => {
|
) => {
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||||
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
|
||||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||||
const showPaginator = pages.length > 1;
|
const showPaginator = pages.length > 1;
|
||||||
const currentPage = pages[page - 1] ?? [];
|
const currentPage = pages[page - 1] ?? [];
|
||||||
|
@ -25,7 +30,7 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsT
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
!isFirstLoad.current && setPage(1);
|
!isFirstLoad.current && setPage(1);
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
}, [ tagsList.filteredTags ]);
|
}, [ sortedTags ]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollTo(0, 0);
|
scrollTo(0, 0);
|
||||||
}, [ page ]);
|
}, [ page ]);
|
||||||
|
@ -35,23 +40,22 @@ export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsT
|
||||||
<table className="table table-hover mb-0">
|
<table className="table table-hover mb-0">
|
||||||
<thead className="responsive-table__header">
|
<thead className="responsive-table__header">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Tag</th>
|
<th className="tags-table__header-cell" onClick={orderByColumn('tag')}>
|
||||||
<th className="text-lg-right">Short URLs</th>
|
Tag <TableOrderIcon currentOrder={currentOrder} field="tag" />
|
||||||
<th className="text-lg-right">Visits</th>
|
</th>
|
||||||
<th />
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('shortUrls')}>
|
||||||
|
Short URLs <TableOrderIcon currentOrder={currentOrder} field="shortUrls" />
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell text-lg-right" onClick={orderByColumn('visits')}>
|
||||||
|
Visits <TableOrderIcon currentOrder={currentOrder} field="visits" />
|
||||||
|
</th>
|
||||||
|
<th className="tags-table__header-cell" />
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr><th colSpan={4} className="p-0 border-top-0" /></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||||
{currentPage.map((tag) => (
|
{currentPage.map((tag) => <TagsTableRow key={tag.tag} tag={tag} selectedServer={selectedServer} />)}
|
||||||
<TagsTableRow
|
|
||||||
key={tag}
|
|
||||||
tag={tag}
|
|
||||||
tagStats={tagsList.stats[tag]}
|
|
||||||
selectedServer={selectedServer}
|
|
||||||
colorGenerator={colorGenerator}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|
|
@ -9,18 +9,18 @@ import { prettify } from '../utils/helpers/numbers';
|
||||||
import { useToggle } from '../utils/helpers/hooks';
|
import { useToggle } from '../utils/helpers/hooks';
|
||||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||||
import TagBullet from './helpers/TagBullet';
|
import TagBullet from './helpers/TagBullet';
|
||||||
import { TagModalProps, TagStats } from './data';
|
import { NormalizedTag, TagModalProps } from './data';
|
||||||
|
|
||||||
export interface TagsTableRowProps {
|
export interface TagsTableRowProps {
|
||||||
tag: string;
|
tag: NormalizedTag;
|
||||||
tagStats?: TagStats;
|
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
colorGenerator: ColorGenerator;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
export const TagsTableRow = (
|
||||||
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||||
) => {
|
EditTagModal: FC<TagModalProps>,
|
||||||
|
colorGenerator: ColorGenerator,
|
||||||
|
) => ({ tag, selectedServer }: TagsTableRowProps) => {
|
||||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||||
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||||
|
@ -29,16 +29,16 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
|
||||||
return (
|
return (
|
||||||
<tr className="responsive-table__row">
|
<tr className="responsive-table__row">
|
||||||
<th className="responsive-table__cell" data-th="Tag">
|
<th className="responsive-table__cell" data-th="Tag">
|
||||||
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag}
|
<TagBullet tag={tag.tag} colorGenerator={colorGenerator} /> {tag.tag}
|
||||||
</th>
|
</th>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||||
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}>
|
<Link to={`/server/${serverId}/list-short-urls/1?tags=${encodeURIComponent(tag.tag)}`}>
|
||||||
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
{prettify(tag.shortUrls)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||||
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
<Link to={`/server/${serverId}/tag/${tag.tag}/visits`}>
|
||||||
{prettify(tagStats?.visitsCount ?? 0)}
|
{prettify(tag.visits)}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="responsive-table__cell text-lg-right">
|
<td className="responsive-table__cell text-lg-right">
|
||||||
|
@ -52,8 +52,8 @@ export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagMo
|
||||||
</DropdownBtnMenu>
|
</DropdownBtnMenu>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
<EditTagModal tag={tag.tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
<DeleteTagConfirmModal tag={tag.tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,7 +1,18 @@
|
||||||
import { TagsList as TagsListState } from '../reducers/tagsList';
|
|
||||||
import { SelectedServer } from '../../servers/data';
|
import { SelectedServer } from '../../servers/data';
|
||||||
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
|
import { NormalizedTag } from './index';
|
||||||
|
|
||||||
|
export const SORTABLE_FIELDS = {
|
||||||
|
tag: 'Tag',
|
||||||
|
shortUrls: 'Short URLs',
|
||||||
|
visits: 'Visits',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OrderableFields = keyof typeof SORTABLE_FIELDS;
|
||||||
|
|
||||||
|
export type TagsOrder = Order<OrderableFields>;
|
||||||
|
|
||||||
export interface TagsListChildrenProps {
|
export interface TagsListChildrenProps {
|
||||||
tagsList: TagsListState;
|
sortedTags: NormalizedTag[];
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,3 +8,9 @@ export interface TagModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NormalizedTag {
|
||||||
|
tag: string;
|
||||||
|
shortUrls: number;
|
||||||
|
visits: number;
|
||||||
|
}
|
||||||
|
|
|
@ -44,7 +44,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
||||||
try {
|
try {
|
||||||
await deleteTags([ tag ]);
|
await deleteTags([ tag ]);
|
||||||
dispatch({ type: DELETE_TAG });
|
dispatch({ type: DELETE_TAG });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -59,7 +59,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
||||||
await editTag(oldName, newName);
|
await editTag(oldName, newName);
|
||||||
colorGenerator.setColorForKey(newName, color);
|
colorGenerator.setColorForKey(newName, color);
|
||||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
|
|
|
@ -126,7 +126,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -27,9 +27,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||||
|
|
||||||
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||||
|
|
||||||
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
bottle.serviceFactory('TagsTable', TagsTable, 'TagsTableRow');
|
||||||
bottle.decorator('TagsTable', withRouter);
|
bottle.decorator('TagsTable', withRouter);
|
||||||
|
|
||||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { FC, useRef } from 'react';
|
import { FC, useRef } from 'react';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { InputType } from 'reactstrap/lib/Input';
|
import { InputType } from 'reactstrap/lib/Input';
|
||||||
|
import { FormGroup } from 'reactstrap';
|
||||||
|
|
||||||
export interface FormGroupContainerProps {
|
export interface FormGroupContainerProps {
|
||||||
value: string;
|
value: string;
|
||||||
|
@ -19,7 +20,7 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||||
const forId = useRef<string>(id ?? uuid());
|
const forId = useRef<string>(id ?? uuid());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`form-group ${className ?? ''}`}>
|
<FormGroup className={className ?? ''}>
|
||||||
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
||||||
{children}:
|
{children}:
|
||||||
</label>
|
</label>
|
||||||
|
@ -32,6 +33,6 @@ export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,10 +12,11 @@ interface SearchFieldProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
|
initialValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
const SearchField = ({ onChange, className, large = true, noBorder = false, initialValue = '' }: SearchFieldProps) => {
|
||||||
const [ searchTerm, setSearchTerm ] = useState('');
|
const [ searchTerm, setSearchTerm ] = useState(initialValue);
|
||||||
|
|
||||||
const resetTimer = () => {
|
const resetTimer = () => {
|
||||||
timer && clearTimeout(timer);
|
timer && clearTimeout(timer);
|
||||||
|
|
|
@ -3,23 +3,22 @@ import { toPairs } from 'ramda';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSortAmountUp as sortAscIcon, faSortAmountDown as sortDescIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { determineOrderDir, OrderDir } from './utils';
|
import { determineOrderDir, Order, OrderDir } from './helpers/ordering';
|
||||||
import './SortingDropdown.scss';
|
import './SortingDropdown.scss';
|
||||||
|
|
||||||
export interface SortingDropdownProps<T extends string = string> {
|
export interface SortingDropdownProps<T extends string = string> {
|
||||||
items: Record<T, string>;
|
items: Record<T, string>;
|
||||||
orderField?: T;
|
order: Order<T>;
|
||||||
orderDir?: OrderDir;
|
|
||||||
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
onChange: (orderField?: T, orderDir?: OrderDir) => void;
|
||||||
isButton?: boolean;
|
isButton?: boolean;
|
||||||
right?: boolean;
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SortingDropdown<T extends string = string>(
|
export default function SortingDropdown<T extends string = string>(
|
||||||
{ items, orderField, orderDir, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
|
{ items, order, onChange, isButton = true, right = false }: SortingDropdownProps<T>,
|
||||||
) {
|
) {
|
||||||
const handleItemClick = (fieldKey: T) => () => {
|
const handleItemClick = (fieldKey: T) => () => {
|
||||||
const newOrderDir = determineOrderDir(fieldKey, orderField, orderDir);
|
const newOrderDir = determineOrderDir(fieldKey, order.field, order.dir);
|
||||||
|
|
||||||
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
onChange(newOrderDir ? fieldKey : undefined, newOrderDir);
|
||||||
};
|
};
|
||||||
|
@ -32,26 +31,26 @@ export default function SortingDropdown<T extends string = string>(
|
||||||
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
|
className={classNames({ 'dropdown-btn__toggle btn-block': isButton, 'btn-sm p-0': !isButton })}
|
||||||
>
|
>
|
||||||
{!isButton && <>Order by</>}
|
{!isButton && <>Order by</>}
|
||||||
{isButton && !orderField && <>Order by...</>}
|
{isButton && !order.field && <>Order by...</>}
|
||||||
{isButton && orderField && `Order by: "${items[orderField]}" - "${orderDir ?? 'DESC'}"`}
|
{isButton && order.field && `Order by: "${items[order.field]}" - "${order.dir ?? 'DESC'}"`}
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu
|
<DropdownMenu
|
||||||
right={right}
|
right={right}
|
||||||
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
|
className={classNames('w-100', { 'sorting-dropdown__menu--link': !isButton })}
|
||||||
>
|
>
|
||||||
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
{toPairs(items).map(([ fieldKey, fieldValue ]) => (
|
||||||
<DropdownItem key={fieldKey} active={orderField === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
<DropdownItem key={fieldKey} active={order.field === fieldKey} onClick={handleItemClick(fieldKey as T)}>
|
||||||
{fieldValue}
|
{fieldValue}
|
||||||
{orderField === fieldKey && (
|
{order.field === fieldKey && (
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={orderDir === 'ASC' ? sortAscIcon : sortDescIcon}
|
icon={order.dir === 'ASC' ? sortAscIcon : sortDescIcon}
|
||||||
className="sorting-dropdown__sort-icon"
|
className="sorting-dropdown__sort-icon"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
))}
|
))}
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem disabled={!orderField} onClick={() => onChange()}>
|
<DropdownItem disabled={!order.field} onClick={() => onChange()}>
|
||||||
<i>Clear selection</i>
|
<i>Clear selection</i>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
|
@ -4,11 +4,16 @@ import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
|
||||||
|
|
||||||
export interface DateIntervalDropdownProps {
|
export interface DateIntervalDropdownProps {
|
||||||
active?: DateInterval;
|
active?: DateInterval;
|
||||||
|
allText: string;
|
||||||
onChange: (interval: DateInterval) => void;
|
onChange: (interval: DateInterval) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, onChange }) => (
|
export const DateIntervalDropdownItems: FC<DateIntervalDropdownProps> = ({ active, allText, onChange }) => (
|
||||||
<>
|
<>
|
||||||
|
<DropdownItem active={active === 'all'} onClick={() => onChange('all')}>
|
||||||
|
{allText}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem divider />
|
||||||
{DATE_INTERVALS.map(
|
{DATE_INTERVALS.map(
|
||||||
(interval) => (
|
(interval) => (
|
||||||
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}>
|
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval)}>
|
||||||
|
|
|
@ -3,8 +3,8 @@ import { DropdownBtn } from '../DropdownBtn';
|
||||||
import { rangeOrIntervalToString } from './types';
|
import { rangeOrIntervalToString } from './types';
|
||||||
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
||||||
|
|
||||||
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active }) => (
|
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
||||||
<DropdownBtn text={rangeOrIntervalToString(active) ?? ''}>
|
<DropdownBtn text={rangeOrIntervalToString(active) ?? allText}>
|
||||||
<DateIntervalDropdownItems active={active} onChange={onChange} />
|
<DateIntervalDropdownItems allText={allText} active={active} onChange={onChange} />
|
||||||
</DropdownBtn>
|
</DropdownBtn>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,10 +4,10 @@ import { DropdownBtn } from '../DropdownBtn';
|
||||||
import {
|
import {
|
||||||
DateInterval,
|
DateInterval,
|
||||||
DateRange,
|
DateRange,
|
||||||
dateRangeIsEmpty,
|
|
||||||
rangeOrIntervalToString,
|
rangeOrIntervalToString,
|
||||||
intervalToDateRange,
|
intervalToDateRange,
|
||||||
rangeIsInterval,
|
rangeIsInterval,
|
||||||
|
dateRangeIsEmpty,
|
||||||
} from './types';
|
} from './types';
|
||||||
import DateRangeRow from './DateRangeRow';
|
import DateRangeRow from './DateRangeRow';
|
||||||
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
||||||
|
@ -22,18 +22,16 @@ export interface DateRangeSelectorProps {
|
||||||
export const DateRangeSelector = (
|
export const DateRangeSelector = (
|
||||||
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
|
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps,
|
||||||
) => {
|
) => {
|
||||||
const [ activeInterval, setActiveInterval ] = useState(
|
const initialIntervalIsRange = rangeIsInterval(initialDateRange);
|
||||||
rangeIsInterval(initialDateRange) ? initialDateRange : undefined,
|
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
|
||||||
);
|
const [ activeDateRange, setActiveDateRange ] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
||||||
const [ activeDateRange, setActiveDateRange ] = useState(
|
|
||||||
!rangeIsInterval(initialDateRange) ? initialDateRange : undefined,
|
|
||||||
);
|
|
||||||
const updateDateRange = (dateRange: DateRange) => {
|
const updateDateRange = (dateRange: DateRange) => {
|
||||||
setActiveInterval(undefined);
|
setActiveInterval(dateRangeIsEmpty(dateRange) ? 'all' : undefined);
|
||||||
setActiveDateRange(dateRange);
|
setActiveDateRange(dateRange);
|
||||||
onDatesChange(dateRange);
|
onDatesChange(dateRange);
|
||||||
};
|
};
|
||||||
const updateInterval = (dateInterval?: DateInterval) => () => {
|
const updateInterval = (dateInterval: DateInterval) => {
|
||||||
setActiveInterval(dateInterval);
|
setActiveInterval(dateInterval);
|
||||||
setActiveDateRange(undefined);
|
setActiveDateRange(undefined);
|
||||||
onDatesChange(intervalToDateRange(dateInterval));
|
onDatesChange(intervalToDateRange(dateInterval));
|
||||||
|
@ -41,14 +39,7 @@ export const DateRangeSelector = (
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
|
||||||
<DropdownItem
|
<DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />
|
||||||
active={activeInterval === undefined && dateRangeIsEmpty(activeDateRange)}
|
|
||||||
onClick={updateInterval(undefined)}
|
|
||||||
>
|
|
||||||
{defaultText}
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem divider />
|
|
||||||
<DateIntervalDropdownItems active={activeInterval} onChange={(interval) => updateInterval(interval)()} />
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem header>Custom:</DropdownItem>
|
<DropdownItem header>Custom:</DropdownItem>
|
||||||
<DropdownItem text>
|
<DropdownItem text>
|
||||||
|
|
|
@ -7,14 +7,15 @@ export interface DateRange {
|
||||||
endDate?: Date | null;
|
endDate?: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateInterval = 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
|
export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days';
|
||||||
|
|
||||||
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|| isEmpty(filter(Boolean, dateRange as any));
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
|
||||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string';
|
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
||||||
|
typeof range === 'string';
|
||||||
|
|
||||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, 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',
|
||||||
|
@ -22,9 +23,10 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string> = {
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_INTERVALS: DateInterval[] = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[];
|
export const DATE_INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP).filter((value) => value !== 'all') as DateInterval[];
|
||||||
|
|
||||||
const dateRangeToString = (range?: DateRange): string | undefined => {
|
const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
if (!range || dateRangeIsEmpty(range)) {
|
if (!range || dateRangeIsEmpty(range)) {
|
||||||
|
@ -43,7 +45,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) {
|
if (!range || range === 'all') {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,7 +60,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) {
|
if (!dateInterval || dateInterval === 'all') {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { format, formatISO, parse } from 'date-fns';
|
import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
|
||||||
import { OptionalString } from '../utils';
|
import { OptionalString } from '../utils';
|
||||||
|
|
||||||
type DateOrString = Date | string;
|
type DateOrString = Date | string;
|
||||||
|
@ -21,3 +21,21 @@ export const formatIsoDate = (date?: NullableDate) => formatDateFromFormat(date,
|
||||||
export const formatInternational = formatDate();
|
export const formatInternational = formatDate();
|
||||||
|
|
||||||
export const parseDate = (date: string, format: string) => parse(date, format, new Date());
|
export const parseDate = (date: string, format: string) => parse(date, format, new Date());
|
||||||
|
|
||||||
|
const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
|
||||||
|
|
||||||
|
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
||||||
|
if (!start && end) {
|
||||||
|
return isBefore(parseISO(date), parseISO(end));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start && !end) {
|
||||||
|
return isAfter(parseISO(date), parseISO(start));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start && end) {
|
||||||
|
return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
|
@ -21,3 +21,5 @@ export const supportsCrawlableVisits = supportsBotVisits;
|
||||||
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
||||||
|
|
||||||
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
||||||
|
|
||||||
|
export const supportsForwardQuery = serverMatchesVersions({ minVersion: '2.9.0' });
|
||||||
|
|
32
src/utils/helpers/ordering.ts
Normal file
32
src/utils/helpers/ordering.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
||||||
|
|
||||||
|
export interface Order<Fields> {
|
||||||
|
field?: Fields;
|
||||||
|
dir?: OrderDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const determineOrderDir = <T extends string = string>(
|
||||||
|
currentField: T,
|
||||||
|
newField?: T,
|
||||||
|
currentOrderDir?: OrderDir,
|
||||||
|
): OrderDir => {
|
||||||
|
if (currentField !== newField) {
|
||||||
|
return 'ASC';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
|
||||||
|
ASC: 'DESC',
|
||||||
|
DESC: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sortList = <List>(list: List[], { field, dir }: Order<Partial<keyof List>>) => !field || !dir
|
||||||
|
? list
|
||||||
|
: list.sort((a, b) => {
|
||||||
|
const greaterThan = dir === 'ASC' ? 1 : -1;
|
||||||
|
const smallerThan = dir === 'ASC' ? -1 : 1;
|
||||||
|
|
||||||
|
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||||
|
});
|
|
@ -1,6 +1,6 @@
|
||||||
@import '../base';
|
@import '../base';
|
||||||
|
|
||||||
@mixin sticky-cell() {
|
@mixin sticky-cell($with-separators: true) {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
@ -11,20 +11,20 @@
|
||||||
top: -1px;
|
top: -1px;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
right: -1px;
|
right: if($with-separators, -1px, 0);
|
||||||
background: var(--table-border-color);
|
background: var(--table-border-color);
|
||||||
z-index: -2;
|
z-index: -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:first-child:before {
|
&:first-child:before {
|
||||||
left: -1px;
|
left: if($with-separators, -1px, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:after {
|
&:after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 1px;
|
left: if($with-separators, 1px, 0);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: var(--primary-color);
|
background: var(--primary-color);
|
||||||
|
|
19
src/utils/table/TableOrderIcon.tsx
Normal file
19
src/utils/table/TableOrderIcon.tsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCaretDown as caretDownIcon, faCaretUp as caretUpIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { Order } from '../helpers/ordering';
|
||||||
|
|
||||||
|
interface TableOrderIconProps<T> {
|
||||||
|
currentOrder: Order<T>;
|
||||||
|
field: T;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableOrderIcon<T extends string = string>(
|
||||||
|
{ currentOrder, field, className = 'ml-1' }: TableOrderIconProps<T>,
|
||||||
|
) {
|
||||||
|
if (!currentOrder.dir || currentOrder.field !== field) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FontAwesomeIcon icon={currentOrder.dir === 'ASC' ? caretUpIcon : caretDownIcon} className={className} />;
|
||||||
|
}
|
|
@ -1,25 +1,6 @@
|
||||||
import { isEmpty, isNil, pipe, range } from 'ramda';
|
import { isEmpty, isNil, pipe, range } from 'ramda';
|
||||||
import { SyntheticEvent } from 'react';
|
import { SyntheticEvent } from 'react';
|
||||||
|
|
||||||
export type OrderDir = 'ASC' | 'DESC' | undefined;
|
|
||||||
|
|
||||||
export const determineOrderDir = <T extends string = string>(
|
|
||||||
currentField: T,
|
|
||||||
newField?: T,
|
|
||||||
currentOrderDir?: OrderDir,
|
|
||||||
): OrderDir => {
|
|
||||||
if (currentField !== newField) {
|
|
||||||
return 'ASC';
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOrderMap: Record<'ASC' | 'DESC', OrderDir> = {
|
|
||||||
ASC: 'DESC',
|
|
||||||
DESC: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return currentOrderDir ? newOrderMap[currentOrderDir] : 'ASC';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] =>
|
export const rangeOf = <T>(size: number, mappingFn: (value: number) => T, startAt = 1): T[] =>
|
||||||
range(startAt, size + 1).map(mappingFn);
|
range(startAt, size + 1).map(mappingFn);
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { supportsBotVisits } from '../utils/helpers/features';
|
import { supportsBotVisits } from '../utils/helpers/features';
|
||||||
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
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, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
||||||
|
@ -295,7 +296,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
className="btn-md-block mr-2"
|
className="btn-md-block mr-2"
|
||||||
onClick={() => setSelectedVisits([])}
|
onClick={() => setSelectedVisits([])}
|
||||||
>
|
>
|
||||||
Clear selection {highlightedVisits.length > 0 && <>({highlightedVisits.length})</>}
|
Clear selection {highlightedVisits.length > 0 && <>({prettify(highlightedVisits.length)})</>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
outline
|
outline
|
||||||
|
@ -303,7 +304,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
className="btn-md-block"
|
className="btn-md-block"
|
||||||
onClick={() => exportCsv(normalizedVisits)}
|
onClick={() => exportCsv(normalizedVisits)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faFileDownload} /> Export ({normalizedVisits.length})
|
<FontAwesomeIcon icon={faFileDownload} /> Export ({prettify(normalizedVisits.length)})
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,21 +1,17 @@
|
||||||
import { useEffect, useMemo, useState, useRef } from 'react';
|
import { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { min, splitEvery } from 'ramda';
|
import { min, splitEvery } from 'ramda';
|
||||||
import {
|
import { faCheck as checkIcon, faRobot as botIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
faCaretDown as caretDownIcon,
|
|
||||||
faCaretUp as caretUpIcon,
|
|
||||||
faCheck as checkIcon,
|
|
||||||
faRobot as botIcon,
|
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import SimplePaginator from '../common/SimplePaginator';
|
import SimplePaginator from '../common/SimplePaginator';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
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/Time';
|
||||||
|
import { TableOrderIcon } from '../utils/table/TableOrderIcon';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||||
import './VisitsTable.scss';
|
import './VisitsTable.scss';
|
||||||
|
|
||||||
|
@ -29,11 +25,7 @@ export interface VisitsTableProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl' | 'potentialBot';
|
||||||
|
type VisitsOrder = Order<OrderableFields>;
|
||||||
interface Order {
|
|
||||||
field?: OrderableFields;
|
|
||||||
dir?: OrderDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
||||||
|
@ -42,15 +34,8 @@ const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: No
|
||||||
);
|
);
|
||||||
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
||||||
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||||
const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort(
|
const sortVisits = (order: VisitsOrder, visits: NormalizedVisit[]) => sortList<NormalizedVisit>(visits, order as any);
|
||||||
(a, b) => {
|
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: VisitsOrder) => {
|
||||||
const greaterThan = dir === 'ASC' ? 1 : -1;
|
|
||||||
const smallerThan = dir === 'ASC' ? -1 : 1;
|
|
||||||
|
|
||||||
return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => {
|
|
||||||
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
|
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
|
||||||
const sortedVisits = sortVisits(order, filteredVisits);
|
const sortedVisits = sortVisits(order, filteredVisits);
|
||||||
const total = sortedVisits.length;
|
const total = sortedVisits.length;
|
||||||
|
@ -72,7 +57,7 @@ const VisitsTable = ({
|
||||||
|
|
||||||
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
||||||
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
|
const [ searchTerm, setSearchTerm ] = useState<string | undefined>(undefined);
|
||||||
const [ order, setOrder ] = useState<Order>({ field: undefined, dir: undefined });
|
const [ order, setOrder ] = useState<VisitsOrder>({});
|
||||||
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
|
const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
const [ page, setPage ] = useState(1);
|
const [ page, setPage ] = useState(1);
|
||||||
|
@ -83,12 +68,8 @@ const VisitsTable = ({
|
||||||
|
|
||||||
const orderByColumn = (field: OrderableFields) =>
|
const orderByColumn = (field: OrderableFields) =>
|
||||||
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
() => setOrder({ field, dir: determineOrderDir(field, order.field, order.dir) });
|
||||||
const renderOrderIcon = (field: OrderableFields) => order.dir && order.field === field && (
|
const renderOrderIcon = (field: OrderableFields) =>
|
||||||
<FontAwesomeIcon
|
<TableOrderIcon currentOrder={order} field={field} className="visits-table__header-icon" />;
|
||||||
icon={order.dir === 'ASC' ? caretUpIcon : caretDownIcon}
|
|
||||||
className="visits-table__header-icon"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const listener = () => setIsMobileDevice(matchMobile());
|
const listener = () => setIsMobileDevice(matchMobile());
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { FC, useState } from 'react';
|
import { FC, useState } from 'react';
|
||||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
import { rangeOf } from '../../utils/utils';
|
||||||
|
import { Order } from '../../utils/helpers/ordering';
|
||||||
import SimplePaginator from '../../common/SimplePaginator';
|
import SimplePaginator from '../../common/SimplePaginator';
|
||||||
import { roundTen } from '../../utils/helpers/numbers';
|
import { roundTen } from '../../utils/helpers/numbers';
|
||||||
import SortingDropdown from '../../utils/SortingDropdown';
|
import SortingDropdown from '../../utils/SortingDropdown';
|
||||||
|
@ -29,24 +30,21 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
withPagination = true,
|
withPagination = true,
|
||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
const [ order, setOrder ] = useState<Order<string>>({});
|
||||||
orderField: undefined,
|
|
||||||
orderDir: undefined,
|
|
||||||
});
|
|
||||||
const [ currentPage, setCurrentPage ] = useState(1);
|
const [ currentPage, setCurrentPage ] = useState(1);
|
||||||
const [ itemsPerPage, setItemsPerPage ] = useState(50);
|
const [ itemsPerPage, setItemsPerPage ] = useState(50);
|
||||||
|
|
||||||
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
|
const getSortedPairsForStats = (stats: Stats, sortingItems: Record<string, string>) => {
|
||||||
const pairs = toPairs(stats);
|
const pairs = toPairs(stats);
|
||||||
const sortedPairs = !order.orderField ? pairs : sortBy(
|
const sortedPairs = !order.field ? pairs : sortBy(
|
||||||
pipe<StatsRow, string | number, string | number>(
|
pipe<StatsRow, string | number, string | number>(
|
||||||
order.orderField === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
|
order.field === Object.keys(sortingItems)[0] ? pickKeyFromPair : pickValueFromPair,
|
||||||
toLowerIfString,
|
toLowerIfString,
|
||||||
),
|
),
|
||||||
pairs,
|
pairs,
|
||||||
);
|
);
|
||||||
|
|
||||||
return !order.orderDir || order.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
return !order.dir || order.dir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||||
};
|
};
|
||||||
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
|
const determineCurrentPagePairs = (pages: StatsRow[][]): StatsRow[] => {
|
||||||
const page = pages[currentPage - 1];
|
const page = pages[currentPage - 1];
|
||||||
|
@ -102,10 +100,9 @@ export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||||
isButton={false}
|
isButton={false}
|
||||||
right
|
right
|
||||||
items={sortingItems}
|
items={sortingItems}
|
||||||
orderField={order.orderField}
|
order={order}
|
||||||
orderDir={order.orderDir}
|
onChange={(field, dir) => {
|
||||||
onChange={(orderField, orderDir) => {
|
setOrder({ field, dir });
|
||||||
setOrder({ orderField, orderDir });
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -72,7 +72,7 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
|
||||||
const visits = await loadVisits();
|
const visits = await loadVisits();
|
||||||
|
|
||||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { GetState } from '../../container/types';
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { isOrphanVisit } from '../types/helpers';
|
import { isOrphanVisit } from '../types/helpers';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -20,6 +21,7 @@ export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHA
|
||||||
|
|
||||||
export interface OrphanVisitsAction extends Action<string> {
|
export interface OrphanVisitsAction extends Action<string> {
|
||||||
visits: Visit[];
|
visits: Visit[];
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||||
|
@ -39,13 +41,16 @@ const initialState: VisitsInfo = {
|
||||||
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
|
||||||
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_ORPHAN_VISITS]: (_, { visits }) => ({ ...initialState, visits }),
|
[GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }),
|
||||||
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { visits } = state;
|
const { visits, query = {} } = state;
|
||||||
const newVisits = createdVisits.map(({ visit }) => visit);
|
const { startDate, endDate } = query;
|
||||||
|
const newVisits = createdVisits
|
||||||
|
.filter(({ visit }) => isBetween(visit.date, startDate, endDate))
|
||||||
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...newVisits, ...visits ] };
|
return { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
},
|
},
|
||||||
|
@ -66,6 +71,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
return { ...result, data: visits };
|
return { ...result, data: visits };
|
||||||
});
|
});
|
||||||
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
const shouldCancel = () => getState().orphanVisits.cancelLoad;
|
||||||
|
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
start: GET_ORPHAN_VISITS_START,
|
start: GET_ORPHAN_VISITS_START,
|
||||||
large: GET_ORPHAN_VISITS_LARGE,
|
large: GET_ORPHAN_VISITS_LARGE,
|
||||||
|
@ -74,7 +80,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
||||||
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
|
||||||
};
|
};
|
||||||
|
|
||||||
return getVisitsWithLoader(visitsLoader, {}, actionMap, dispatch, shouldCancel);
|
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -23,6 +24,7 @@ export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
|
||||||
|
|
||||||
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
||||||
visits: Visit[];
|
visits: Visit[];
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||||
|
@ -33,7 +35,7 @@ type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||||
const initialState: ShortUrlVisits = {
|
const initialState: ShortUrlVisits = {
|
||||||
visits: [],
|
visits: [],
|
||||||
shortCode: '',
|
shortCode: '',
|
||||||
domain: undefined,
|
domain: undefined, // Deprecated. Value from query params can be used instead
|
||||||
loading: false,
|
loading: false,
|
||||||
loadingLarge: false,
|
loadingLarge: false,
|
||||||
error: false,
|
error: false,
|
||||||
|
@ -44,22 +46,27 @@ const initialState: ShortUrlVisits = {
|
||||||
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
|
||||||
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_SHORT_URL_VISITS]: (_, { visits, shortCode, domain }) => ({
|
[GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
visits,
|
visits,
|
||||||
shortCode,
|
shortCode,
|
||||||
domain,
|
domain,
|
||||||
|
query,
|
||||||
}),
|
}),
|
||||||
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { shortCode, domain, visits } = state;
|
const { shortCode, domain, visits, query = {} } = state;
|
||||||
|
const { startDate, endDate } = query;
|
||||||
const newVisits = createdVisits
|
const newVisits = createdVisits
|
||||||
.filter(({ shortUrl }) => shortUrl && shortUrlMatches(shortUrl, shortCode, domain))
|
.filter(
|
||||||
|
({ shortUrl, visit }) =>
|
||||||
|
shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
|
||||||
|
)
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...newVisits, ...visits ] };
|
return newVisits.length === 0 ? state : { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
},
|
},
|
||||||
}, initialState);
|
}, initialState);
|
||||||
|
|
||||||
|
@ -73,7 +80,7 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
||||||
{ ...query, page, itemsPerPage },
|
{ ...query, page, itemsPerPage },
|
||||||
);
|
);
|
||||||
const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
|
const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
|
||||||
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, domain: query.domain };
|
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
start: GET_SHORT_URL_VISITS_START,
|
start: GET_SHORT_URL_VISITS_START,
|
||||||
large: GET_SHORT_URL_VISITS_LARGE,
|
large: GET_SHORT_URL_VISITS_LARGE,
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
||||||
import { GetState } from '../../container/types';
|
import { GetState } from '../../container/types';
|
||||||
import { ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkVisitsParams } from '../../api/types';
|
||||||
import { ApiErrorAction } from '../../api/types/actions';
|
import { ApiErrorAction } from '../../api/types/actions';
|
||||||
|
import { isBetween } from '../../utils/helpers/date';
|
||||||
import { getVisitsWithLoader } from './common';
|
import { getVisitsWithLoader } from './common';
|
||||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||||
|
|
||||||
|
@ -24,6 +25,7 @@ export interface TagVisits extends VisitsInfo {
|
||||||
export interface TagVisitsAction extends Action<string> {
|
export interface TagVisitsAction extends Action<string> {
|
||||||
visits: Visit[];
|
visits: Visit[];
|
||||||
tag: string;
|
tag: string;
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagsVisitsCombinedAction = TagVisitsAction
|
type TagsVisitsCombinedAction = TagVisitsAction
|
||||||
|
@ -44,14 +46,15 @@ const initialState: TagVisits = {
|
||||||
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
|
||||||
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
|
||||||
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||||
[GET_TAG_VISITS]: (_, { visits, tag }) => ({ ...initialState, visits, tag }),
|
[GET_TAG_VISITS]: (_, { visits, tag, query }) => ({ ...initialState, visits, tag, query }),
|
||||||
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
|
||||||
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
|
||||||
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
|
||||||
[CREATE_VISITS]: (state, { createdVisits }) => {
|
[CREATE_VISITS]: (state, { createdVisits }) => {
|
||||||
const { tag, visits } = state;
|
const { tag, visits, query = {} } = state;
|
||||||
|
const { startDate, endDate } = query;
|
||||||
const newVisits = createdVisits
|
const newVisits = createdVisits
|
||||||
.filter(({ shortUrl }) => shortUrl?.tags.includes(tag))
|
.filter(({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate))
|
||||||
.map(({ visit }) => visit);
|
.map(({ visit }) => visit);
|
||||||
|
|
||||||
return { ...state, visits: [ ...newVisits, ...visits ] };
|
return { ...state, visits: [ ...newVisits, ...visits ] };
|
||||||
|
@ -68,7 +71,7 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||||
{ ...query, page, itemsPerPage },
|
{ ...query, page, itemsPerPage },
|
||||||
);
|
);
|
||||||
const shouldCancel = () => getState().tagVisits.cancelLoad;
|
const shouldCancel = () => getState().tagVisits.cancelLoad;
|
||||||
const extraFinishActionData: Partial<TagVisitsAction> = { tag };
|
const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
|
||||||
const actionMap = {
|
const actionMap = {
|
||||||
start: GET_TAG_VISITS_START,
|
start: GET_TAG_VISITS_START,
|
||||||
large: GET_TAG_VISITS_LARGE,
|
large: GET_TAG_VISITS_LARGE,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Action } from 'redux';
|
import { Action } from 'redux';
|
||||||
import { ShortUrl } from '../../short-urls/data';
|
import { ShortUrl } from '../../short-urls/data';
|
||||||
import { ProblemDetailsError } from '../../api/types';
|
import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types';
|
||||||
import { DateRange } from '../../utils/dates/types';
|
import { DateRange } from '../../utils/dates/types';
|
||||||
|
|
||||||
export interface VisitsInfo {
|
export interface VisitsInfo {
|
||||||
|
@ -11,6 +11,7 @@ export interface VisitsInfo {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
progress: number;
|
progress: number;
|
||||||
cancelLoad: boolean;
|
cancelLoad: boolean;
|
||||||
|
query?: ShlinkVisitsParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VisitsLoadProgressChangedAction extends Action<string> {
|
export interface VisitsLoadProgressChangedAction extends Action<string> {
|
||||||
|
|
|
@ -11,7 +11,16 @@ describe('<App />', () => {
|
||||||
const ShlinkVersions = () => null;
|
const ShlinkVersions = () => null;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const App = appFactory(MainHeader, () => null, () => null, () => null, () => null, () => null, ShlinkVersions);
|
const App = appFactory(
|
||||||
|
MainHeader,
|
||||||
|
() => null,
|
||||||
|
() => null,
|
||||||
|
() => null,
|
||||||
|
() => null,
|
||||||
|
() => null,
|
||||||
|
() => null,
|
||||||
|
ShlinkVersions,
|
||||||
|
);
|
||||||
|
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<App
|
<App
|
||||||
|
@ -36,6 +45,7 @@ describe('<App />', () => {
|
||||||
const expectedPaths = [
|
const expectedPaths = [
|
||||||
'/',
|
'/',
|
||||||
'/settings',
|
'/settings',
|
||||||
|
'/manage-servers',
|
||||||
'/server/create',
|
'/server/create',
|
||||||
'/server/:serverId/edit',
|
'/server/:serverId/edit',
|
||||||
'/server/:serverId',
|
'/server/:serverId',
|
||||||
|
|
|
@ -24,7 +24,7 @@ describe('<AppUpdateBanner />', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes toggle when alert is toggled', () => {
|
it('invokes toggle when alert is toggled', () => {
|
||||||
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.prop('toggle') as Function)();
|
||||||
|
|
||||||
expect(toggle).toHaveBeenCalled();
|
expect(toggle).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { shallow, ShallowWrapper } from 'enzyme';
|
import { shallow, ShallowWrapper } from 'enzyme';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
|
import { RouteChildrenProps } from 'react-router-dom';
|
||||||
import Home, { HomeProps } from '../../src/common/Home';
|
import Home, { HomeProps } from '../../src/common/Home';
|
||||||
import { ServerWithId } from '../../src/servers/data';
|
import { ServerWithId } from '../../src/servers/data';
|
||||||
import { ShlinkLogo } from '../../src/common/img/ShlinkLogo';
|
import { ShlinkLogo } from '../../src/common/img/ShlinkLogo';
|
||||||
|
@ -7,6 +8,7 @@ import { ShlinkLogo } from '../../src/common/img/ShlinkLogo';
|
||||||
describe('<Home />', () => {
|
describe('<Home />', () => {
|
||||||
let wrapped: ShallowWrapper;
|
let wrapped: ShallowWrapper;
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
|
...Mock.all<RouteChildrenProps>(),
|
||||||
resetSelectedServer: jest.fn(),
|
resetSelectedServer: jest.fn(),
|
||||||
servers: {},
|
servers: {},
|
||||||
};
|
};
|
||||||
|
|
|
@ -42,7 +42,7 @@ describe('<EditDomainRedirectsModal />', () => {
|
||||||
it('has different handlers to toggle the modal', () => {
|
it('has different handlers to toggle the modal', () => {
|
||||||
expect(toggle).not.toHaveBeenCalled();
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
|
|
||||||
(wrapper.prop('toggle') as Function)(); // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
|
(wrapper.prop('toggle') as Function)();
|
||||||
(wrapper.find(ModalHeader).prop('toggle') as Function)();
|
(wrapper.find(ModalHeader).prop('toggle') as Function)();
|
||||||
wrapper.find(Button).first().simulate('click');
|
wrapper.find(Button).first().simulate('click');
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue