mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
commit
785806b7a1
155 changed files with 3239 additions and 1190 deletions
|
@ -14,5 +14,8 @@
|
|||
"process": true,
|
||||
"setImmediate": true
|
||||
},
|
||||
"ignorePatterns": ["src/service*.ts"]
|
||||
"ignorePatterns": ["src/service*.ts"],
|
||||
"rules": {
|
||||
"complexity": "off"
|
||||
}
|
||||
}
|
||||
|
|
18
.github/workflows/deploy-preview.yml
vendored
18
.github/workflows/deploy-preview.yml
vendored
|
@ -17,25 +17,13 @@ jobs:
|
|||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14.15
|
||||
- name: Generate slug
|
||||
id: generate_slug
|
||||
run: echo "##[set-output name=slug;]$(echo ${GITHUB_HEAD_REF#refs/heads/} | sed -r 's/[~\^]+//g' | sed -r 's/[^a-zA-Z0-9]+/-/g' | sed -r 's/^-+\|-+$//g' | tr A-Z a-z)"
|
||||
- name: Build
|
||||
run: |
|
||||
npm ci && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${{ steps.generate_slug.outputs.slug }} && \
|
||||
node ./scripts/set-homepage.js /shlink-web-client/${GITHUB_HEAD_REF#refs/heads/} && \
|
||||
rm src/service-worker.ts && \
|
||||
npm run build
|
||||
- name: Deploy
|
||||
uses: JamesIves/github-pages-deploy-action@4.1.1
|
||||
- name: Deploy preview
|
||||
uses: shlinkio/deploy-preview-action@v1.0.1
|
||||
with:
|
||||
branch: preview-env
|
||||
folder: build
|
||||
target-folder: ${{ steps.generate_slug.outputs.slug }}
|
||||
- name: Publish env
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: Preview environment
|
||||
message: |
|
||||
## Preview environment
|
||||
https://shlinkio.github.io/shlink-web-client/${{ steps.generate_slug.outputs.slug }}/
|
||||
|
|
57
CHANGELOG.md
57
CHANGELOG.md
|
@ -4,6 +4,39 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## [3.3.0] - 2021-09-25
|
||||
### Added
|
||||
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
|
||||
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
|
||||
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
|
||||
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
|
||||
|
||||
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
|
||||
* `includes`: Suggests tags that contain the input.
|
||||
|
||||
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
|
||||
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
|
||||
* [#459](https://github.com/shlinkio/shlink-web-client/issues/459) Added new list mode to display tags.
|
||||
|
||||
The mode is optional, and you can toggle between the classic cards mode or the new list mode whenever you want.
|
||||
|
||||
You can also configure the default mode from settings.
|
||||
|
||||
### Changed
|
||||
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
|
||||
* [#486](https://github.com/shlinkio/shlink-web-client/issues/486) Refactored components used to render visits charts, making them easier to maintain and understand.
|
||||
* [#409](https://github.com/shlinkio/shlink-web-client/issues/409) Increased required code coverage and added hard threshold check.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
### Removed
|
||||
* [#491](https://github.com/shlinkio/shlink-web-client/issues/491) Dropped support for Shlink older than v2.4.0.
|
||||
|
||||
### Fixed
|
||||
* *Nothing*
|
||||
|
||||
|
||||
## [3.2.1] - 2021-09-12
|
||||
### Added
|
||||
* *Nothing*
|
||||
|
@ -18,9 +51,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||
* [#480](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||
* [#482](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
|
||||
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
|
||||
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
|
||||
|
||||
|
||||
## [3.2.0] - 2021-07-12
|
||||
|
@ -32,16 +65,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
|
||||
|
||||
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
|
||||
* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
||||
* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
||||
* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
||||
* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design.
|
||||
* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
||||
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
|
||||
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
|
||||
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
|
||||
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
|
||||
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
|
||||
|
||||
### Changed
|
||||
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
|
||||
* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns.
|
||||
* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
||||
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
|
||||
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
|
||||
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
@ -50,7 +83,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
* *Nothing*
|
||||
|
||||
### Fixed
|
||||
* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
||||
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
|
||||
|
||||
|
||||
## [3.1.2] - 2021-06-06
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
module.exports = {
|
||||
coverageDirectory: '<rootDir>/coverage',
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts,tsx}',
|
||||
'!src/registerServiceWorker.js',
|
||||
'!src/index.ts',
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/*.{ts,tsx}',
|
||||
'!src/reducers/index.ts',
|
||||
'!src/**/provideServices.ts',
|
||||
'!src/container/*.ts',
|
||||
],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
statements: 85,
|
||||
branches: 75,
|
||||
functions: 80,
|
||||
lines: 85,
|
||||
},
|
||||
},
|
||||
resolver: 'jest-pnp-resolver',
|
||||
setupFiles: [
|
||||
'react-app-polyfill/jsdom',
|
||||
|
|
63
package-lock.json
generated
63
package-lock.json
generated
|
@ -6363,15 +6363,6 @@
|
|||
"@babel/types": "^7.3.0"
|
||||
}
|
||||
},
|
||||
"@types/chart.js": {
|
||||
"version": "2.9.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz",
|
||||
"integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"moment": "^2.10.2"
|
||||
}
|
||||
},
|
||||
"@types/cheerio": {
|
||||
"version": "0.22.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
|
||||
|
@ -10578,30 +10569,9 @@
|
|||
"dev": true
|
||||
},
|
||||
"chart.js": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
|
||||
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
|
||||
"requires": {
|
||||
"chartjs-color": "^2.1.0",
|
||||
"moment": "^2.10.2"
|
||||
}
|
||||
},
|
||||
"chartjs-color": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
|
||||
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
|
||||
"requires": {
|
||||
"chartjs-color-string": "^0.6.0",
|
||||
"color-convert": "^1.9.3"
|
||||
}
|
||||
},
|
||||
"chartjs-color-string": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
|
||||
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
|
||||
"requires": {
|
||||
"color-name": "^1.0.0"
|
||||
}
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
|
||||
"integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
|
||||
},
|
||||
"check-types": {
|
||||
"version": "11.1.2",
|
||||
|
@ -10957,6 +10927,7 @@
|
|||
"version": "1.9.3",
|
||||
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
},
|
||||
|
@ -10964,14 +10935,16 @@
|
|||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
|
||||
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI="
|
||||
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=",
|
||||
"dev": true
|
||||
},
|
||||
"color-string": {
|
||||
"version": "1.5.4",
|
||||
|
@ -19000,11 +18973,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"moment": {
|
||||
"version": "2.29.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
|
||||
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
|
||||
},
|
||||
"moo": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
|
||||
|
@ -24570,18 +24538,17 @@
|
|||
}
|
||||
},
|
||||
"react-chartjs-2": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz",
|
||||
"integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
|
||||
"integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
|
||||
"requires": {
|
||||
"lodash": "^4.17.19",
|
||||
"prop-types": "^15.7.2"
|
||||
"lodash": "^4.17.19"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
15
package.json
15
package.json
|
@ -7,16 +7,18 @@
|
|||
"license": "MIT",
|
||||
"scripts": {
|
||||
"lint": "npm run lint:css && npm run lint:js",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
||||
"lint:js": "eslint --ext .js,.ts,.tsx src test",
|
||||
"lint:fix": "npm run lint:css:fix && npm run lint:js:fix",
|
||||
"lint:css:fix": "npm run lint:css -- --fix",
|
||||
"lint:js:fix": "npm run lint:js -- --fix",
|
||||
"start": "node scripts/start.js",
|
||||
"serve:build": "serve ./build",
|
||||
"build": "node scripts/build.js",
|
||||
"test": "node scripts/test.js --env=jsdom --colors --verbose",
|
||||
"test:ci": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=clover",
|
||||
"test:pretty": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary --coverageReporters=html",
|
||||
"test:coverage": "npm run test -- --coverage --coverageReporters=text --coverageReporters=text-summary",
|
||||
"test:ci": "npm run test:coverage -- --coverageReporters=clover",
|
||||
"test:pretty": "npm run test:coverage -- --coverageReporters=html",
|
||||
"mutate": "./node_modules/.bin/stryker run --concurrency 4"
|
||||
},
|
||||
"dependencies": {
|
||||
|
@ -29,7 +31,7 @@
|
|||
"bootstrap": "^4.6.0",
|
||||
"bottlejs": "^2.0.0",
|
||||
"bowser": "^2.11.0",
|
||||
"chart.js": "^2.9.4",
|
||||
"chart.js": "^3.5.1",
|
||||
"classnames": "^2.2.6",
|
||||
"compare-versions": "^3.6.0",
|
||||
"csvjson": "^5.1.0",
|
||||
|
@ -40,7 +42,7 @@
|
|||
"qs": "^6.9.6",
|
||||
"ramda": "^0.27.1",
|
||||
"react": "^17.0.1",
|
||||
"react-chartjs-2": "^2.11.1",
|
||||
"react-chartjs-2": "^3.0.4",
|
||||
"react-color": "^2.19.3",
|
||||
"react-copy-to-clipboard": "^5.0.2",
|
||||
"react-datepicker": "^3.6.0",
|
||||
|
@ -71,7 +73,6 @@
|
|||
"@stryker-mutator/jest-runner": "^5.0.0",
|
||||
"@stryker-mutator/typescript-checker": "^5.0.0",
|
||||
"@svgr/webpack": "^5.5.0",
|
||||
"@types/chart.js": "^2.9.31",
|
||||
"@types/classnames": "^2.2.11",
|
||||
"@types/enzyme": "^3.10.8",
|
||||
"@types/jest": "^26.0.20",
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import qs from 'qs';
|
||||
import { isEmpty, isNil, reject } from 'ramda';
|
||||
import { AxiosInstance, AxiosResponse, Method } from 'axios';
|
||||
import { ShortUrlsListParams } from '../../short-urls/reducers/shortUrlsListParams';
|
||||
|
@ -16,7 +15,10 @@ import {
|
|||
ShlinkDomain,
|
||||
ShlinkDomainsResponse,
|
||||
ShlinkVisitsOverview,
|
||||
ShlinkEditDomainRedirects,
|
||||
ShlinkDomainRedirects,
|
||||
} from '../types';
|
||||
import { stringifyQuery } from '../../utils/helpers/query';
|
||||
|
||||
const buildShlinkBaseUrl = (url: string, apiVersion: number) => url ? `${url}/rest/v${apiVersion}` : '';
|
||||
const rejectNilProps = reject(isNil);
|
||||
|
@ -108,6 +110,11 @@ export default class ShlinkApiClient {
|
|||
public readonly listDomains = async (): Promise<ShlinkDomain[]> =>
|
||||
this.performRequest<{ domains: ShlinkDomainsResponse }>('/domains', 'GET').then(({ data }) => data.domains.data);
|
||||
|
||||
public readonly editDomainRedirects = async (
|
||||
domainRedirects: ShlinkEditDomainRedirects,
|
||||
): Promise<ShlinkDomainRedirects> =>
|
||||
this.performRequest<ShlinkDomainRedirects>('/domains/redirects', 'PATCH', {}, domainRedirects).then(({ data }) => data);
|
||||
|
||||
private readonly performRequest = async <T>(url: string, method: Method = 'GET', query = {}, body = {}): Promise<AxiosResponse<T>> => {
|
||||
try {
|
||||
return await this.axios({
|
||||
|
@ -116,7 +123,7 @@ export default class ShlinkApiClient {
|
|||
headers: { 'X-Api-Key': this.apiKey },
|
||||
params: rejectNilProps(query),
|
||||
data: body,
|
||||
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
|
||||
paramsSerializer: stringifyQuery,
|
||||
});
|
||||
} catch (e) {
|
||||
const { response } = e;
|
||||
|
|
6
src/api/types/actions.ts
Normal file
6
src/api/types/actions.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { Action } from 'redux';
|
||||
import { ProblemDetailsError } from './index';
|
||||
|
||||
export interface ApiErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
|
@ -65,9 +65,20 @@ export interface ShlinkShortUrlData extends ShortUrlMeta {
|
|||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface ShlinkDomainRedirects {
|
||||
baseUrlRedirect: string | null;
|
||||
regular404Redirect: string | null;
|
||||
invalidShortUrlRedirect: string | null;
|
||||
}
|
||||
|
||||
export interface ShlinkEditDomainRedirects extends Partial<ShlinkDomainRedirects> {
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export interface ShlinkDomain {
|
||||
domain: string;
|
||||
isDefault: boolean;
|
||||
redirects?: ShlinkDomainRedirects; // Optional only for Shlink older than 2.8
|
||||
}
|
||||
|
||||
export interface ShlinkDomainsResponse {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@import './utils/base';
|
||||
@import '../utils/base';
|
||||
|
||||
.app-container {
|
||||
height: 100%;
|
|
@ -1,11 +1,11 @@
|
|||
import { useEffect, FC } from 'react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import NotFound from './common/NotFound';
|
||||
import { ServersMap } from './servers/data';
|
||||
import { Settings } from './settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from './utils/theme';
|
||||
import { AppUpdateBanner } from './common/AppUpdateBanner';
|
||||
import { forceUpdate } from './utils/helpers/sw';
|
||||
import NotFound from '../common/NotFound';
|
||||
import { ServersMap } from '../servers/data';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
import { changeThemeInMarkup } from '../utils/theme';
|
||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||
import { forceUpdate } from '../utils/helpers/sw';
|
||||
import './App.scss';
|
||||
|
||||
interface AppProps {
|
|
@ -1,6 +1,6 @@
|
|||
import Bottle from 'bottlejs';
|
||||
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
|
||||
import App from '../../App';
|
||||
import App from '../App';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
|
|
|
@ -4,6 +4,7 @@ import {
|
|||
faTags as tagsIcon,
|
||||
faPen as editIcon,
|
||||
faHome as overviewIcon,
|
||||
faGlobe as domainsIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
|
@ -11,11 +12,12 @@ import { NavLink, NavLinkProps } from 'react-router-dom';
|
|||
import classNames from 'classnames';
|
||||
import { Location } from 'history';
|
||||
import { DeleteServerButtonProps } from '../servers/DeleteServerButton';
|
||||
import { ServerWithId } from '../servers/data';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import { supportsDomainRedirects } from '../utils/helpers/features';
|
||||
import './AsideMenu.scss';
|
||||
|
||||
export interface AsideMenuProps {
|
||||
selectedServer: ServerWithId;
|
||||
selectedServer: SelectedServer;
|
||||
className?: string;
|
||||
showOnMobile?: boolean;
|
||||
}
|
||||
|
@ -38,7 +40,8 @@ const AsideMenuItem: FC<AsideMenuItemProps> = ({ children, to, className, ...res
|
|||
const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
||||
{ selectedServer, showOnMobile = false }: AsideMenuProps,
|
||||
) => {
|
||||
const serverId = selectedServer ? selectedServer.id : '';
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const addManageDomainsLink = supportsDomainRedirects(selectedServer);
|
||||
const asideClass = classNames('aside-menu', {
|
||||
'aside-menu--hidden': !showOnMobile,
|
||||
});
|
||||
|
@ -49,30 +52,38 @@ const AsideMenu = (DeleteServerButton: FC<DeleteServerButtonProps>) => (
|
|||
<aside className={asideClass}>
|
||||
<nav className="nav flex-column aside-menu__nav">
|
||||
<AsideMenuItem to={buildPath('/overview')}>
|
||||
<FontAwesomeIcon icon={overviewIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={overviewIcon} />
|
||||
<span className="aside-menu__item-text">Overview</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/list-short-urls/1')} isActive={shortUrlsIsActive}>
|
||||
<FontAwesomeIcon icon={listIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={listIcon} />
|
||||
<span className="aside-menu__item-text">List short URLs</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/create-short-url')}>
|
||||
<FontAwesomeIcon icon={createIcon} flip="horizontal" />
|
||||
<FontAwesomeIcon fixedWidth icon={createIcon} flip="horizontal" />
|
||||
<span className="aside-menu__item-text">Create short URL</span>
|
||||
</AsideMenuItem>
|
||||
<AsideMenuItem to={buildPath('/manage-tags')}>
|
||||
<FontAwesomeIcon icon={tagsIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={tagsIcon} />
|
||||
<span className="aside-menu__item-text">Manage tags</span>
|
||||
</AsideMenuItem>
|
||||
{addManageDomainsLink && (
|
||||
<AsideMenuItem to={buildPath('/manage-domains')}>
|
||||
<FontAwesomeIcon fixedWidth icon={domainsIcon} />
|
||||
<span className="aside-menu__item-text">Manage domains</span>
|
||||
</AsideMenuItem>
|
||||
)}
|
||||
<AsideMenuItem to={buildPath('/edit')} className="aside-menu__item--push">
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
<FontAwesomeIcon fixedWidth icon={editIcon} />
|
||||
<span className="aside-menu__item-text">Edit this server</span>
|
||||
</AsideMenuItem>
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
{isServerWithId(selectedServer) && (
|
||||
<DeleteServerButton
|
||||
className="aside-menu__item aside-menu__item--danger"
|
||||
textClassName="aside-menu__item-text"
|
||||
server={selectedServer}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
|
|
|
@ -31,7 +31,7 @@ const MainHeader = (ServersDropdown: FC) => ({ location }: RouteComponentProps)
|
|||
<Collapse navbar isOpen={isOpen}>
|
||||
<Nav navbar className="ml-auto">
|
||||
<NavItem>
|
||||
<NavLink tag={Link} to={'/settings'} active={pathname === settingsPath}>
|
||||
<NavLink tag={Link} to={settingsPath} active={pathname === settingsPath}>
|
||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
|
|
|
@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||
import classNames from 'classnames';
|
||||
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
|
||||
import { useSwipeable, useToggle } from '../utils/helpers/hooks';
|
||||
import { supportsOrphanVisits, supportsTagVisits } from '../utils/helpers/features';
|
||||
import { supportsDomainRedirects, supportsOrphanVisits } from '../utils/helpers/features';
|
||||
import { isReachableServer } from '../servers/data';
|
||||
import NotFound from './NotFound';
|
||||
import { AsideMenuProps } from './AsideMenu';
|
||||
|
@ -22,6 +22,7 @@ const MenuLayout = (
|
|||
ServerError: FC,
|
||||
Overview: FC,
|
||||
EditShortUrl: FC,
|
||||
ManageDomains: FC,
|
||||
) => withSelectedServer(({ location, selectedServer }) => {
|
||||
const [ sidebarVisible, toggleSidebar, showSidebar, hideSidebar ] = useToggle();
|
||||
|
||||
|
@ -31,8 +32,8 @@ const MenuLayout = (
|
|||
return <ServerError />;
|
||||
}
|
||||
|
||||
const addTagsVisitsRoute = supportsTagVisits(selectedServer);
|
||||
const addOrphanVisitsRoute = supportsOrphanVisits(selectedServer);
|
||||
const addManageDomainsRoute = supportsDomainRedirects(selectedServer);
|
||||
const burgerClasses = classNames('menu-layout__burger-icon', { 'menu-layout__burger-icon--active': sidebarVisible });
|
||||
const swipeableProps = useSwipeable(showSidebar, hideSidebar);
|
||||
|
||||
|
@ -52,9 +53,10 @@ const MenuLayout = (
|
|||
<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/edit" component={EditShortUrl} />
|
||||
{addTagsVisitsRoute && <Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />}
|
||||
<Route path="/server/:serverId/tag/:tag/visits" component={TagVisits} />
|
||||
{addOrphanVisitsRoute && <Route path="/server/:serverId/orphan-visits" component={OrphanVisits} />}
|
||||
<Route exact path="/server/:serverId/manage-tags" component={TagsList} />
|
||||
{addManageDomainsRoute && <Route exact path="/server/:serverId/manage-domains" component={ManageDomains} />}
|
||||
<Route
|
||||
render={() => <NotFound to={`/server/${selectedServer.id}/list-short-urls/1`}>List short URLs</NotFound>}
|
||||
/>
|
||||
|
|
13
src/common/services/ImageDownloader.ts
Normal file
13
src/common/services/ImageDownloader.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { AxiosInstance } from 'axios';
|
||||
import { saveUrl } from '../../utils/helpers/files';
|
||||
|
||||
export class ImageDownloader {
|
||||
public constructor(private readonly axios: AxiosInstance, private readonly window: Window) {}
|
||||
|
||||
public async saveImage(imgUrl: string, filename: string): Promise<void> {
|
||||
const { data } = await this.axios.get(imgUrl, { responseType: 'blob' });
|
||||
const url = URL.createObjectURL(data);
|
||||
|
||||
saveUrl(this.window, url, filename);
|
||||
}
|
||||
}
|
|
@ -9,12 +9,17 @@ import ErrorHandler from '../ErrorHandler';
|
|||
import ShlinkVersionsContainer from '../ShlinkVersionsContainer';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
|
||||
import { ImageDownloader } from './ImageDownloader';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter: Decorator) => {
|
||||
// Services
|
||||
bottle.constant('window', (global as any).window);
|
||||
bottle.constant('console', global.console);
|
||||
bottle.constant('axios', axios);
|
||||
|
||||
bottle.service('ImageDownloader', ImageDownloader, 'axios', 'window');
|
||||
|
||||
// Components
|
||||
bottle.serviceFactory('ScrollToTop', ScrollToTop);
|
||||
bottle.decorator('ScrollToTop', withRouter);
|
||||
|
||||
|
@ -38,6 +43,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator, withRouter:
|
|||
'ServerError',
|
||||
'Overview',
|
||||
'EditShortUrl',
|
||||
'ManageDomains',
|
||||
);
|
||||
bottle.decorator('MenuLayout', connect([ 'selectedServer', 'shortUrlsListParams' ], [ 'selectServer' ]));
|
||||
bottle.decorator('MenuLayout', withRouter);
|
||||
|
|
73
src/domains/DomainRow.tsx
Normal file
73
src/domains/DomainRow.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { FC } from 'react';
|
||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faBan as forbiddenIcon,
|
||||
faCheck as defaultDomainIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../api/types';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { OptionalString } from '../utils/utils';
|
||||
import { EditDomainRedirectsModal } from './helpers/EditDomainRedirectsModal';
|
||||
|
||||
interface DomainRowProps {
|
||||
domain: ShlinkDomain;
|
||||
defaultRedirects?: ShlinkDomainRedirects;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const Nr: FC<{ fallback: OptionalString }> = ({ fallback }) => (
|
||||
<span className="text-muted">
|
||||
{!fallback && <small>No redirect</small>}
|
||||
{fallback && <>{fallback} <small>(as fallback)</small></>}
|
||||
</span>
|
||||
);
|
||||
const DefaultDomain: FC = () => (
|
||||
<>
|
||||
<FontAwesomeIcon icon={defaultDomainIcon} className="text-primary" id="defaultDomainIcon" />
|
||||
<UncontrolledTooltip target="defaultDomainIcon" placement="right">Default domain</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
export const DomainRow: FC<DomainRowProps> = ({ domain, editDomainRedirects, defaultRedirects }) => {
|
||||
const [ isOpen, toggle ] = useToggle();
|
||||
const { domain: authority, isDefault, redirects } = domain;
|
||||
const domainId = `domainEdit${authority.replace('.', '')}`;
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<td className="responsive-table__cell" data-th="Is default domain">{isDefault ? <DefaultDomain /> : ''}</td>
|
||||
<th className="responsive-table__cell" data-th="Domain">{authority}</th>
|
||||
<td className="responsive-table__cell" data-th="Base path redirect">
|
||||
{redirects?.baseUrlRedirect ?? <Nr fallback={defaultRedirects?.baseUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Regular 404 redirect">
|
||||
{redirects?.regular404Redirect ?? <Nr fallback={defaultRedirects?.regular404Redirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell" data-th="Invalid short URL redirect">
|
||||
{redirects?.invalidShortUrlRedirect ?? <Nr fallback={defaultRedirects?.invalidShortUrlRedirect} />}
|
||||
</td>
|
||||
<td className="responsive-table__cell text-right">
|
||||
<span id={domainId}>
|
||||
<Button outline size="sm" disabled={isDefault} onClick={isDefault ? undefined : toggle}>
|
||||
<FontAwesomeIcon icon={isDefault ? forbiddenIcon : editIcon} />
|
||||
</Button>
|
||||
</span>
|
||||
{isDefault && (
|
||||
<UncontrolledTooltip target={domainId} placement="left">
|
||||
Redirects for default domain cannot be edited here.
|
||||
<br />
|
||||
Use config options or env vars directly on the server.
|
||||
</UncontrolledTooltip>
|
||||
)}
|
||||
</td>
|
||||
<EditDomainRedirectsModal
|
||||
domain={domain}
|
||||
isOpen={isOpen}
|
||||
toggle={toggle}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
};
|
71
src/domains/ManageDomains.tsx
Normal file
71
src/domains/ManageDomains.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { FC, useEffect } from 'react';
|
||||
import Message from '../utils/Message';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { ShlinkDomainRedirects } from '../api/types';
|
||||
import { DomainsList } from './reducers/domainsList';
|
||||
import { DomainRow } from './DomainRow';
|
||||
|
||||
interface ManageDomainsProps {
|
||||
listDomains: Function;
|
||||
filterDomains: (searchTerm: string) => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
domainsList: DomainsList;
|
||||
}
|
||||
|
||||
const headers = [ '', 'Domain', 'Base path redirect', 'Regular 404 redirect', 'Invalid short URL redirect', '' ];
|
||||
|
||||
export const ManageDomains: FC<ManageDomainsProps> = (
|
||||
{ listDomains, domainsList, filterDomains, editDomainRedirects },
|
||||
) => {
|
||||
const { filteredDomains: domains, loading, error, errorData } = domainsList;
|
||||
const defaultRedirects = domains.find(({ isDefault }) => isDefault)?.redirects;
|
||||
|
||||
useEffect(() => {
|
||||
listDomains();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
<ShlinkApiError errorData={errorData} fallbackMessage="Error loading domains :(" />
|
||||
</Result>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SimpleCard>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>{headers.map((column, index) => <th key={index}>{column}</th>)}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length < 1 && <tr><td colSpan={headers.length} className="text-center">No results found</td></tr>}
|
||||
{domains.map((domain) => (
|
||||
<DomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
editDomainRedirects={editDomainRedirects}
|
||||
defaultRedirects={defaultRedirects}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SearchField className="mb-3" onChange={filterDomains} />
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
};
|
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
72
src/domains/helpers/EditDomainRedirectsModal.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
||||
import { ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { handleEventPreventingDefault, nonEmptyValueOrNull } from '../../utils/utils';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
interface EditDomainRedirectsModalProps {
|
||||
domain: ShlinkDomain;
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
editDomainRedirects: (domain: string, redirects: Partial<ShlinkDomainRedirects>) => Promise<void>;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps & { isLast?: boolean }> = ({ isLast, ...rest }) => (
|
||||
<FormGroupContainer
|
||||
{...rest}
|
||||
required={false}
|
||||
type="url"
|
||||
placeholder="No redirect"
|
||||
className={isLast ? 'mb-0' : ''}
|
||||
/>
|
||||
);
|
||||
|
||||
export const EditDomainRedirectsModal: FC<EditDomainRedirectsModalProps> = (
|
||||
{ isOpen, toggle, domain, editDomainRedirects },
|
||||
) => {
|
||||
const [ baseUrlRedirect, setBaseUrlRedirect ] = useState(domain.redirects?.baseUrlRedirect ?? '');
|
||||
const [ regular404Redirect, setRegular404Redirect ] = useState(domain.redirects?.regular404Redirect ?? '');
|
||||
const [ invalidShortUrlRedirect, setInvalidShortUrlRedirect ] = useState(
|
||||
domain.redirects?.invalidShortUrlRedirect ?? '',
|
||||
);
|
||||
const handleSubmit = handleEventPreventingDefault(async () => editDomainRedirects(domain.domain, {
|
||||
baseUrlRedirect: nonEmptyValueOrNull(baseUrlRedirect),
|
||||
regular404Redirect: nonEmptyValueOrNull(regular404Redirect),
|
||||
invalidShortUrlRedirect: nonEmptyValueOrNull(invalidShortUrlRedirect),
|
||||
}).then(toggle));
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<ModalHeader toggle={toggle}>Edit redirects for <b>{domain.domain}</b></ModalHeader>
|
||||
<ModalBody>
|
||||
<FormGroup value={baseUrlRedirect} onChange={setBaseUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing the base url, as in <b>https://{domain.domain}/</b>, will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Base URL
|
||||
</FormGroup>
|
||||
<FormGroup value={regular404Redirect} onChange={setRegular404Redirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing a url not matching a short URL pattern, as in <b>https://{domain.domain}/???/[...]</b>,
|
||||
will be redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Regular 404
|
||||
</FormGroup>
|
||||
<FormGroup value={invalidShortUrlRedirect} isLast onChange={setInvalidShortUrlRedirect}>
|
||||
<InfoTooltip className="mr-2" placement="bottom">
|
||||
Visitors accessing a url matching a short URL pattern, but not matching an existing short code, will be
|
||||
redirected to this URL.
|
||||
</InfoTooltip>
|
||||
Invalid short URL
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="link" type="button" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary">Save</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
33
src/domains/reducers/domainRedirects.ts
Normal file
33
src/domains/reducers/domainRedirects.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ShlinkDomainRedirects } from '../../api/types';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_DOMAIN_REDIRECTS_START = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_START';
|
||||
export const EDIT_DOMAIN_REDIRECTS_ERROR = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS_ERROR';
|
||||
export const EDIT_DOMAIN_REDIRECTS = 'shlink/domainRedirects/EDIT_DOMAIN_REDIRECTS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface EditDomainRedirectsAction extends Action<string> {
|
||||
domain: string;
|
||||
redirects: ShlinkDomainRedirects;
|
||||
}
|
||||
|
||||
export const editDomainRedirects = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
||||
domain: string,
|
||||
domainRedirects: Partial<ShlinkDomainRedirects>,
|
||||
) => async (dispatch: Dispatch, getState: GetState) => {
|
||||
dispatch({ type: EDIT_DOMAIN_REDIRECTS_START });
|
||||
const { editDomainRedirects } = buildShlinkApiClient(getState);
|
||||
|
||||
try {
|
||||
const redirects = await editDomainRedirects({ domain, ...domainRedirects });
|
||||
|
||||
dispatch<EditDomainRedirectsAction>({ type: EDIT_DOMAIN_REDIRECTS, domain, redirects });
|
||||
} catch (e) {
|
||||
dispatch<ApiErrorAction>({ type: EDIT_DOMAIN_REDIRECTS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
|
@ -1,35 +1,63 @@
|
|||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkDomain } from '../../api/types';
|
||||
import { ProblemDetailsError, ShlinkDomain, ShlinkDomainRedirects } from '../../api/types';
|
||||
import { buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { EDIT_DOMAIN_REDIRECTS, EditDomainRedirectsAction } from './domainRedirects';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const LIST_DOMAINS_START = 'shlink/domainsList/LIST_DOMAINS_START';
|
||||
export const LIST_DOMAINS_ERROR = 'shlink/domainsList/LIST_DOMAINS_ERROR';
|
||||
export const LIST_DOMAINS = 'shlink/domainsList/LIST_DOMAINS';
|
||||
export const FILTER_DOMAINS = 'shlink/domainsList/FILTER_DOMAINS';
|
||||
/* eslint-enable padding-line-between-statements */
|
||||
|
||||
export interface DomainsList {
|
||||
domains: ShlinkDomain[];
|
||||
filteredDomains: ShlinkDomain[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
export interface ListDomainsAction extends Action<string> {
|
||||
domains: ShlinkDomain[];
|
||||
}
|
||||
|
||||
interface FilterDomainsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
const initialState: DomainsList = {
|
||||
domains: [],
|
||||
filteredDomains: [],
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<DomainsList, ListDomainsAction>({
|
||||
export type DomainsCombinedAction = ListDomainsAction
|
||||
& ApiErrorAction
|
||||
& FilterDomainsAction
|
||||
& EditDomainRedirectsAction;
|
||||
|
||||
export const replaceRedirectsOnDomain = (domain: string, redirects: ShlinkDomainRedirects) =>
|
||||
(d: ShlinkDomain): ShlinkDomain => d.domain !== domain ? d : { ...d, redirects };
|
||||
|
||||
export default buildReducer<DomainsList, DomainsCombinedAction>({
|
||||
[LIST_DOMAINS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_DOMAINS_ERROR]: () => ({ ...initialState, error: true }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains }),
|
||||
[LIST_DOMAINS_ERROR]: ({ errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_DOMAINS]: (_, { domains }) => ({ ...initialState, domains, filteredDomains: domains }),
|
||||
[FILTER_DOMAINS]: (state, { searchTerm }) => ({
|
||||
...state,
|
||||
filteredDomains: state.domains.filter(({ domain }) => domain.toLowerCase().match(searchTerm)),
|
||||
}),
|
||||
[EDIT_DOMAIN_REDIRECTS]: (state, { domain, redirects }) => ({
|
||||
...state,
|
||||
domains: state.domains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
filteredDomains: state.filteredDomains.map(replaceRedirectsOnDomain(domain, redirects)),
|
||||
}),
|
||||
}, initialState);
|
||||
|
||||
export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async (
|
||||
|
@ -44,6 +72,8 @@ export const listDomains = (buildShlinkApiClient: ShlinkApiClientBuilder) => ()
|
|||
|
||||
dispatch<ListDomainsAction>({ type: LIST_DOMAINS, domains });
|
||||
} catch (e) {
|
||||
dispatch({ type: LIST_DOMAINS_ERROR });
|
||||
dispatch<ApiErrorAction>({ type: LIST_DOMAINS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
export const filterDomains = (searchTerm: string): FilterDomainsAction => ({ type: FILTER_DOMAINS, searchTerm });
|
||||
|
|
|
@ -1,15 +1,25 @@
|
|||
import Bottle from 'bottlejs';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { listDomains } from '../reducers/domainsList';
|
||||
import { filterDomains, listDomains } from '../reducers/domainsList';
|
||||
import { DomainSelector } from '../DomainSelector';
|
||||
import { ManageDomains } from '../ManageDomains';
|
||||
import { editDomainRedirects } from '../reducers/domainRedirects';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('DomainSelector', () => DomainSelector);
|
||||
bottle.decorator('DomainSelector', connect([ 'domainsList' ], [ 'listDomains' ]));
|
||||
|
||||
bottle.serviceFactory('ManageDomains', () => ManageDomains);
|
||||
bottle.decorator('ManageDomains', connect(
|
||||
[ 'domainsList' ],
|
||||
[ 'listDomains', 'filterDomains', 'editDomainRedirects' ],
|
||||
));
|
||||
|
||||
// Actions
|
||||
bottle.serviceFactory('listDomains', listDomains, 'buildShlinkApiClient');
|
||||
bottle.serviceFactory('filterDomains', () => filterDomains);
|
||||
bottle.serviceFactory('editDomainRedirects', editDomainRedirects, 'buildShlinkApiClient');
|
||||
};
|
||||
|
||||
export default provideServices;
|
||||
|
|
|
@ -4,6 +4,8 @@
|
|||
@import 'node_modules/bootstrap/scss/bootstrap.scss';
|
||||
@import './common/react-tag-autocomplete.scss';
|
||||
@import './theme/theme';
|
||||
@import './utils/table/ResponsiveTable';
|
||||
@import './utils/StickyCardPaginator';
|
||||
|
||||
* {
|
||||
outline: none !important;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
export class Topics {
|
||||
public static visits = () => 'https://shlink.io/new-visit';
|
||||
public static readonly visits = 'https://shlink.io/new-visit';
|
||||
|
||||
public static shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
public static readonly orphanVisits = 'https://shlink.io/new-orphan-visit';
|
||||
|
||||
public static orphanVisits = () => 'https://shlink.io/new-orphan-visit';
|
||||
public static readonly shortUrlVisits = (shortCode: string) => `https://shlink.io/new-visit/${shortCode}`;
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ const DeleteServerButton = (DeleteServerModal: FC<DeleteServerModalProps>): FC<D
|
|||
return (
|
||||
<>
|
||||
<span className={className} onClick={showModal}>
|
||||
{!children && <FontAwesomeIcon icon={deleteIcon} />}
|
||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
||||
</span>
|
||||
|
||||
|
|
|
@ -55,14 +55,7 @@ export const Overview = (
|
|||
<div className="col-md-6 col-xl-3">
|
||||
<Card className="overview__card mb-3" body>
|
||||
<CardTitle tag="h5" className="overview__card-title">Visits</CardTitle>
|
||||
<CardText tag="h2">
|
||||
<ForServerVersion minVersion="2.2.0">
|
||||
{loadingVisits ? 'Loading...' : prettify(visitsCount)}
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.1.*">
|
||||
<small className="text-muted"><i>Shlink 2.2 is needed</i></small>
|
||||
</ForServerVersion>
|
||||
</CardText>
|
||||
<CardText tag="h2">{loadingVisits ? 'Loading...' : prettify(visitsCount)}</CardText>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="col-md-6 col-xl-3">
|
||||
|
@ -120,4 +113,4 @@ export const Overview = (
|
|||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits(), Topics.orphanVisits() ]);
|
||||
}, () => [ Topics.visits, Topics.orphanVisits ]);
|
||||
|
|
|
@ -40,3 +40,5 @@ export const isReachableServer = (server: SelectedServer): server is ReachableSe
|
|||
|
||||
export const isNotFoundServer = (server: SelectedServer): server is NotFoundServer =>
|
||||
!!server?.hasOwnProperty('serverNotFound');
|
||||
|
||||
export const getServerId = (server: SelectedServer) => isServerWithId(server) ? server.id : '';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { FC, ReactNode, useEffect, useState } from 'react';
|
||||
import { FormGroupContainer } from '../../utils/FormGroupContainer';
|
||||
import { FormGroupContainer, FormGroupContainerProps } from '../../utils/FormGroupContainer';
|
||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
||||
import { ServerData } from '../data';
|
||||
import { SimpleCard } from '../../utils/SimpleCard';
|
||||
|
@ -11,6 +11,9 @@ interface ServerFormProps {
|
|||
title?: ReactNode;
|
||||
}
|
||||
|
||||
const FormGroup: FC<FormGroupContainerProps> = (props) =>
|
||||
<FormGroupContainer {...props} labelClassName="create-server__label" />;
|
||||
|
||||
export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, children, title }) => {
|
||||
const [ name, setName ] = useState('');
|
||||
const [ url, setUrl ] = useState('');
|
||||
|
@ -26,9 +29,9 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||
return (
|
||||
<form className="server-form" onSubmit={handleSubmit}>
|
||||
<SimpleCard className="mb-3" title={title}>
|
||||
<FormGroupContainer value={name} onChange={setName}>Name</FormGroupContainer>
|
||||
<FormGroupContainer type="url" value={url} onChange={setUrl}>URL</FormGroupContainer>
|
||||
<FormGroupContainer value={apiKey} onChange={setApiKey}>API key</FormGroupContainer>
|
||||
<FormGroup value={name} onChange={setName}>Name</FormGroup>
|
||||
<FormGroup type="url" value={url} onChange={setUrl}>URL</FormGroup>
|
||||
<FormGroup value={apiKey} onChange={setApiKey}>APIkey</FormGroup>
|
||||
</SimpleCard>
|
||||
|
||||
<div className="text-right">{children}</div>
|
||||
|
|
|
@ -2,7 +2,7 @@ import { dissoc, values } from 'ramda';
|
|||
import { CsvJson } from 'csvjson';
|
||||
import LocalStorage from '../../utils/services/LocalStorage';
|
||||
import { ServersMap } from '../data';
|
||||
import { saveCsv } from '../../utils/helpers/csv';
|
||||
import { saveCsv } from '../../utils/helpers/files';
|
||||
|
||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ const RealTimeUpdates = (
|
|||
placeholder="Immediate"
|
||||
disabled={!realTimeUpdates.enabled}
|
||||
value={intervalValue(realTimeUpdates.interval)}
|
||||
onChange={(e) => setRealTimeUpdatesInterval(Number(e.target.value))}
|
||||
onChange={({ target }) => setRealTimeUpdatesInterval(Number(target.value))}
|
||||
/>
|
||||
{realTimeUpdates.enabled && (
|
||||
<small className="form-text text-muted">
|
||||
|
|
|
@ -20,8 +20,8 @@ const Settings = (RealTimeUpdates: FC, ShortUrlCreation: FC, UserInterface: FC,
|
|||
<NoMenuLayout>
|
||||
<SettingsSections
|
||||
items={[
|
||||
[ <UserInterface />, <ShortUrlCreation /> ], // eslint-disable-line react/jsx-key
|
||||
[ <Visits />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||
[ <UserInterface />, <Visits /> ], // eslint-disable-line react/jsx-key
|
||||
[ <ShortUrlCreation />, <RealTimeUpdates /> ], // eslint-disable-line react/jsx-key
|
||||
]}
|
||||
/>
|
||||
</NoMenuLayout>
|
||||
|
|
|
@ -1,29 +1,62 @@
|
|||
import { FC } from 'react';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import { DropdownItem, FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { Settings, ShortUrlCreationSettings } from './reducers/settings';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { Settings, ShortUrlCreationSettings, TagFilteringMode } from './reducers/settings';
|
||||
|
||||
interface ShortUrlCreationProps {
|
||||
settings: Settings;
|
||||
setShortUrlCreationSettings: (settings: ShortUrlCreationSettings) => void;
|
||||
}
|
||||
|
||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = (
|
||||
{ settings: { shortUrlCreation }, setShortUrlCreationSettings },
|
||||
) => (
|
||||
<SimpleCard title="Short URLs creation" className="h-100">
|
||||
<FormGroup className="mb-0">
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation?.validateUrls ?? false}
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ validateUrls })}
|
||||
>
|
||||
By default, request validation on long URLs when creating new short URLs.
|
||||
const tagFilteringModeText = (tagFilteringMode: TagFilteringMode | undefined): string =>
|
||||
tagFilteringMode === 'includes' ? 'Suggest tags including input' : 'Suggest tags starting with input';
|
||||
const tagFilteringModeHint = (tagFilteringMode: TagFilteringMode | undefined): ReactNode =>
|
||||
tagFilteringMode === 'includes'
|
||||
? <>The list of suggested tags will contain existing ones <b>including</b> provided input.</>
|
||||
: <>The list of suggested tags will contain existing ones <b>starting with</b> provided input.</>;
|
||||
|
||||
export const ShortUrlCreation: FC<ShortUrlCreationProps> = ({ settings, setShortUrlCreationSettings }) => {
|
||||
const shortUrlCreation: ShortUrlCreationSettings = settings.shortUrlCreation ?? { validateUrls: false };
|
||||
const changeTagsFilteringMode = (tagFilteringMode: TagFilteringMode) => () => setShortUrlCreationSettings(
|
||||
{ ...shortUrlCreation ?? { validateUrls: false }, tagFilteringMode },
|
||||
);
|
||||
|
||||
return (
|
||||
<SimpleCard title="Short URLs creation" className="h-100">
|
||||
<FormGroup>
|
||||
<ToggleSwitch
|
||||
checked={shortUrlCreation.validateUrls ?? false}
|
||||
onChange={(validateUrls) => setShortUrlCreationSettings({ ...shortUrlCreation, validateUrls })}
|
||||
>
|
||||
By default, request validation on long URLs when creating new short URLs.
|
||||
<small className="form-text text-muted">
|
||||
The initial state of the <b>Validate URL</b> checkbox will
|
||||
be <b>{shortUrlCreation.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||
</small>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Tag suggestions search mode:</label>
|
||||
<DropdownBtn text={tagFilteringModeText(shortUrlCreation.tagFilteringMode)}>
|
||||
<DropdownItem
|
||||
active={!shortUrlCreation.tagFilteringMode || shortUrlCreation.tagFilteringMode === 'startsWith'}
|
||||
onClick={changeTagsFilteringMode('startsWith')}
|
||||
>
|
||||
{tagFilteringModeText('startsWith')}
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
active={shortUrlCreation.tagFilteringMode === 'includes'}
|
||||
onClick={changeTagsFilteringMode('includes')}
|
||||
>
|
||||
{tagFilteringModeText('includes')}
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
<small className="form-text text-muted">
|
||||
The initial state of the <b>Validate URL</b> checkbox will
|
||||
be <b>{shortUrlCreation?.validateUrls ? 'checked' : 'unchecked'}</b>.
|
||||
{tagFilteringModeHint(shortUrlCreation.tagFilteringMode)}
|
||||
</small>
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
import { FC } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FormGroup } from 'reactstrap';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ToggleSwitch from '../utils/ToggleSwitch';
|
||||
import { changeThemeInMarkup, Theme } from '../utils/theme';
|
||||
import { TagsModeDropdown } from '../tags/TagsModeDropdown';
|
||||
import { capitalize } from '../utils/utils';
|
||||
import { Settings, UiSettings } from './reducers/settings';
|
||||
import './UserInterface.scss';
|
||||
|
||||
|
@ -14,17 +17,28 @@ interface UserInterfaceProps {
|
|||
|
||||
export const UserInterface: FC<UserInterfaceProps> = ({ settings: { ui }, setUiSettings }) => (
|
||||
<SimpleCard title="User interface" className="h-100">
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
<FormGroup>
|
||||
<FontAwesomeIcon icon={ui?.theme === 'dark' ? faMoon : faSun} className="user-interface__theme-icon" />
|
||||
<ToggleSwitch
|
||||
checked={ui?.theme === 'dark'}
|
||||
onChange={(useDarkTheme) => {
|
||||
const theme: Theme = useDarkTheme ? 'dark' : 'light';
|
||||
|
||||
setUiSettings({ theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
setUiSettings({ ...ui, theme });
|
||||
changeThemeInMarkup(theme);
|
||||
}}
|
||||
>
|
||||
Use dark theme.
|
||||
</ToggleSwitch>
|
||||
</FormGroup>
|
||||
<FormGroup className="mb-0">
|
||||
<label>Default display mode when managing tags:</label>
|
||||
<TagsModeDropdown
|
||||
mode={ui?.tagsMode ?? 'cards'}
|
||||
renderTitle={(tagsMode) => capitalize(tagsMode)}
|
||||
onChange={(tagsMode) => setUiSettings({ ...ui ?? { theme: 'light' }, tagsMode })}
|
||||
/>
|
||||
<small className="form-text text-muted">Tags will be displayed as <b>{ui?.tagsMode ?? 'cards'}</b>.</small>
|
||||
</FormGroup>
|
||||
</SimpleCard>
|
||||
);
|
||||
|
|
|
@ -12,17 +12,23 @@ export const SET_SETTINGS = 'shlink/realTimeUpdates/SET_SETTINGS';
|
|||
* optional, as old instances of the app will load partial objects from local storage until it is saved again.
|
||||
*/
|
||||
|
||||
interface RealTimeUpdatesSettings {
|
||||
export interface RealTimeUpdatesSettings {
|
||||
enabled: boolean;
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export type TagFilteringMode = 'startsWith' | 'includes';
|
||||
|
||||
export interface ShortUrlCreationSettings {
|
||||
validateUrls: boolean;
|
||||
tagFilteringMode?: TagFilteringMode;
|
||||
}
|
||||
|
||||
export type TagsMode = 'cards' | 'list';
|
||||
|
||||
export interface UiSettings {
|
||||
theme: Theme;
|
||||
tagsMode?: TagsMode;
|
||||
}
|
||||
|
||||
export interface VisitsSettings {
|
||||
|
|
|
@ -2,7 +2,6 @@ import { Link } from 'react-router-dom';
|
|||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import { pageIsEllipsis, keyForPage, progressivePagination, prettifyPageNumber } from '../utils/helpers/pagination';
|
||||
import { ShlinkPaginator } from '../api/types';
|
||||
import './Paginator.scss';
|
||||
|
||||
interface PaginatorProps {
|
||||
paginator?: ShlinkPaginator;
|
||||
|
@ -33,7 +32,7 @@ const Paginator = ({ paginator, serverId }: PaginatorProps) => {
|
|||
));
|
||||
|
||||
return (
|
||||
<Pagination className="short-urls-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}>
|
||||
<PaginationLink
|
||||
previous
|
||||
|
|
|
@ -30,11 +30,7 @@ const SearchBar = (colorGenerator: ColorGenerator) => ({ listShortUrls, shortUrl
|
|||
|
||||
return (
|
||||
<div className="search-bar-container">
|
||||
<SearchField
|
||||
onChange={
|
||||
(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })
|
||||
}
|
||||
/>
|
||||
<SearchField onChange={(searchTerm) => listShortUrls({ ...shortUrlsListParams, searchTerm })} />
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="row">
|
||||
|
|
|
@ -5,13 +5,7 @@ import { isEmpty, pipe, replace, trim } from 'ramda';
|
|||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
import DateInput, { DateInputProps } from '../utils/DateInput';
|
||||
import {
|
||||
supportsCrawlableVisits,
|
||||
supportsListingDomains,
|
||||
supportsSettingShortCodeLength,
|
||||
supportsShortUrlTitle,
|
||||
supportsValidateUrl,
|
||||
} from '../utils/helpers/features';
|
||||
import { supportsCrawlableVisits, supportsShortUrlTitle } from '../utils/helpers/features';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import { handleEventPreventingDefault, hasValue } from '../utils/utils';
|
||||
import Checkbox from '../utils/Checkbox';
|
||||
|
@ -43,7 +37,7 @@ const toDate = (date?: string | Date): Date | undefined => typeof date === 'stri
|
|||
export const ShortUrlForm = (
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
DomainSelector: FC<DomainSelectorProps>,
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity
|
||||
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
|
||||
const [ shortUrlData, setShortUrlData ] = useState(initialState);
|
||||
const isEdit = mode === 'edit';
|
||||
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });
|
||||
|
@ -102,17 +96,13 @@ export const ShortUrlForm = (
|
|||
</>
|
||||
);
|
||||
|
||||
const showDomainSelector = supportsListingDomains(selectedServer);
|
||||
const disableShortCodeLength = !supportsSettingShortCodeLength(selectedServer);
|
||||
const supportsTitle = supportsShortUrlTitle(selectedServer);
|
||||
const showCustomizeCard = supportsTitle || !isEdit;
|
||||
const limitAccessCardClasses = classNames('mb-3', {
|
||||
'col-sm-6': showCustomizeCard,
|
||||
'col-sm-12': !showCustomizeCard,
|
||||
});
|
||||
const showValidateUrl = supportsValidateUrl(selectedServer);
|
||||
const showCrawlableControl = supportsCrawlableVisits(selectedServer);
|
||||
const showExtraValidationsCard = showValidateUrl || showCrawlableControl || !isEdit;
|
||||
|
||||
return (
|
||||
<form className="short-url-form" onSubmit={submit}>
|
||||
|
@ -139,22 +129,16 @@ export const ShortUrlForm = (
|
|||
<div className="col-lg-6">
|
||||
{renderOptionalInput('shortCodeLength', 'Short code length', 'number', {
|
||||
min: 4,
|
||||
disabled: disableShortCodeLength || hasValue(shortUrlData.customSlug),
|
||||
...disableShortCodeLength && {
|
||||
title: 'Shlink 2.1.0 or higher is required to be able to provide the short code length',
|
||||
},
|
||||
disabled: hasValue(shortUrlData.customSlug),
|
||||
})}
|
||||
</div>
|
||||
</Row>
|
||||
{!showDomainSelector && renderOptionalInput('domain', 'Domain', 'text')}
|
||||
{showDomainSelector && (
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
)}
|
||||
<FormGroup>
|
||||
<DomainSelector
|
||||
value={shortUrlData.domain}
|
||||
onChange={(domain?: string) => setShortUrlData({ ...shortUrlData, domain })}
|
||||
/>
|
||||
</FormGroup>
|
||||
</>
|
||||
)}
|
||||
</SimpleCard>
|
||||
|
@ -170,41 +154,37 @@ export const ShortUrlForm = (
|
|||
</div>
|
||||
</Row>
|
||||
|
||||
{showExtraValidationsCard && (
|
||||
<SimpleCard title="Extra checks" className="mb-3">
|
||||
{showValidateUrl && (
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||
checked={shortUrlData.validateUrl}
|
||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||
<SimpleCard title="Extra checks" className="mb-3">
|
||||
<ShortUrlFormCheckboxGroup
|
||||
infoTooltip="If checked, Shlink will try to reach the long URL, failing in case it's not publicly accessible."
|
||||
checked={shortUrlData.validateUrl}
|
||||
onChange={(validateUrl) => setShortUrlData({ ...shortUrlData, validateUrl })}
|
||||
>
|
||||
Validate URL
|
||||
</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 && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Validate URL
|
||||
</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 && (
|
||||
<p>
|
||||
<Checkbox
|
||||
inline
|
||||
className="mr-2"
|
||||
checked={shortUrlData.findIfExists}
|
||||
onChange={(findIfExists) => setShortUrlData({ ...shortUrlData, findIfExists })}
|
||||
>
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
)}
|
||||
Use existing URL if found
|
||||
</Checkbox>
|
||||
<UseExistingIfFoundInfoIcon />
|
||||
</p>
|
||||
)}
|
||||
</SimpleCard>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
@ -99,6 +99,6 @@ const ShortUrlsList = (ShortUrlsTable: FC<ShortUrlsTableProps>) => boundToMercur
|
|||
</Card>
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default ShortUrlsList;
|
||||
|
|
|
@ -1,11 +1,3 @@
|
|||
@import '../utils/base';
|
||||
|
||||
.short-urls-table__header {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-table__header-cell--with-action {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -60,7 +60,7 @@ export const ShortUrlsTable = (ShortUrlsRow: FC<ShortUrlsRowProps>) => ({
|
|||
|
||||
return (
|
||||
<table className={tableClasses}>
|
||||
<thead className="short-urls-table__header">
|
||||
<thead className="responsive-table__header short-urls-table__header">
|
||||
<tr>
|
||||
<th className={orderableColumnsClasses} onClick={orderByColumn?.('dateCreated')}>
|
||||
Created at
|
||||
|
|
|
@ -1,31 +1,44 @@
|
|||
import { useMemo, useState } from 'react';
|
||||
import { Modal, DropdownItem, FormGroup, ModalBody, ModalHeader, Row } from 'reactstrap';
|
||||
import { FC, useMemo, useState } from 'react';
|
||||
import { Modal, FormGroup, ModalBody, ModalHeader, Row, Button } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faFileDownload as downloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { ExternalLink } from 'react-external-link';
|
||||
import classNames from 'classnames';
|
||||
import { ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtn } from '../../utils/DropdownBtn';
|
||||
import { CopyToClipboardIcon } from '../../utils/CopyToClipboardIcon';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat } from '../../utils/helpers/qrCodes';
|
||||
import { supportsQrCodeSizeInQuery, supportsQrCodeSvgFormat, supportsQrCodeMargin } from '../../utils/helpers/features';
|
||||
import { buildQrCodeUrl, QrCodeCapabilities, QrCodeFormat, QrErrorCorrection } from '../../utils/helpers/qrCodes';
|
||||
import {
|
||||
supportsQrCodeSizeInQuery,
|
||||
supportsQrCodeMargin,
|
||||
supportsQrErrorCorrection,
|
||||
} from '../../utils/helpers/features';
|
||||
import { ImageDownloader } from '../../common/services/ImageDownloader';
|
||||
import { Versions } from '../../utils/helpers/version';
|
||||
import { QrFormatDropdown } from './qr-codes/QrFormatDropdown';
|
||||
import './QrCodeModal.scss';
|
||||
import { QrErrorCorrectionDropdown } from './qr-codes/QrErrorCorrectionDropdown';
|
||||
|
||||
interface QrCodeModalConnectProps extends ShortUrlModalProps {
|
||||
selectedServer: SelectedServer;
|
||||
}
|
||||
|
||||
const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps) => {
|
||||
const QrCodeModal = (imageDownloader: ImageDownloader, ForServerVersion: FC<Versions>) => ( // eslint-disable-line
|
||||
{ shortUrl: { shortUrl, shortCode }, toggle, isOpen, selectedServer }: QrCodeModalConnectProps,
|
||||
) => {
|
||||
const [ size, setSize ] = useState(300);
|
||||
const [ margin, setMargin ] = useState(0);
|
||||
const [ format, setFormat ] = useState<QrCodeFormat>('png');
|
||||
const [ errorCorrection, setErrorCorrection ] = useState<QrErrorCorrection>('L');
|
||||
const capabilities: QrCodeCapabilities = useMemo(() => ({
|
||||
useSizeInPath: !supportsQrCodeSizeInQuery(selectedServer),
|
||||
svgIsSupported: supportsQrCodeSvgFormat(selectedServer),
|
||||
marginIsSupported: supportsQrCodeMargin(selectedServer),
|
||||
errorCorrectionIsSupported: supportsQrErrorCorrection(selectedServer),
|
||||
}), [ selectedServer ]);
|
||||
const willRenderThreeControls = capabilities.marginIsSupported !== capabilities.errorCorrectionIsSupported;
|
||||
const qrCodeUrl = useMemo(
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin }, capabilities),
|
||||
[ shortUrl, size, format, margin, capabilities ],
|
||||
() => buildQrCodeUrl(shortUrl, { size, format, margin, errorCorrection }, capabilities),
|
||||
[ shortUrl, size, format, margin, errorCorrection, capabilities ],
|
||||
);
|
||||
const totalSize = useMemo(() => size + margin, [ size, margin ]);
|
||||
const modalSize = useMemo(() => {
|
||||
|
@ -42,60 +55,61 @@ const QrCodeModal = ({ shortUrl: { shortUrl }, toggle, isOpen, selectedServer }:
|
|||
QR code for <ExternalLink href={shortUrl}>{shortUrl}</ExternalLink>
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
<Row className="mb-2">
|
||||
<div
|
||||
className={classNames({
|
||||
'col-md-4': capabilities.marginIsSupported && capabilities.svgIsSupported,
|
||||
'col-md-6': (!capabilities.marginIsSupported && capabilities.svgIsSupported) || (capabilities.marginIsSupported && !capabilities.svgIsSupported),
|
||||
'col-12': !capabilities.marginIsSupported && !capabilities.svgIsSupported,
|
||||
})}
|
||||
<Row>
|
||||
<FormGroup
|
||||
className={classNames({ 'col-md-4': willRenderThreeControls, 'col-md-6': !willRenderThreeControls })}
|
||||
>
|
||||
<FormGroup>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<label className="mb-0">Size: {size}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={size}
|
||||
step={10}
|
||||
min={50}
|
||||
max={1000}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
{capabilities.marginIsSupported && (
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<label className="mb-0">Margin: {margin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={size}
|
||||
step={10}
|
||||
min={50}
|
||||
max={1000}
|
||||
onChange={(e) => setSize(Number(e.target.value))}
|
||||
value={margin}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
{capabilities.marginIsSupported && (
|
||||
<div className={capabilities.svgIsSupported ? 'col-md-4' : 'col-md-6'}>
|
||||
<FormGroup>
|
||||
<label className="mb-0">Margin: {margin}px</label>
|
||||
<input
|
||||
type="range"
|
||||
className="form-control-range"
|
||||
value={margin}
|
||||
step={1}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(e) => setMargin(Number(e.target.value))}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
)}
|
||||
{capabilities.svgIsSupported && (
|
||||
<div className={capabilities.marginIsSupported ? 'col-md-4' : 'col-md-6'}>
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
</div>
|
||||
<FormGroup className={willRenderThreeControls ? 'col-md-4' : 'col-md-6'}>
|
||||
<QrFormatDropdown format={format} setFormat={setFormat} />
|
||||
</FormGroup>
|
||||
{capabilities.errorCorrectionIsSupported && (
|
||||
<FormGroup className="col-md-6">
|
||||
<QrErrorCorrectionDropdown errorCorrection={errorCorrection} setErrorCorrection={setErrorCorrection} />
|
||||
</FormGroup>
|
||||
)}
|
||||
</Row>
|
||||
<div className="text-center">
|
||||
<div className="mb-3">
|
||||
<div>QR code URL:</div>
|
||||
<ExternalLink href={qrCodeUrl} />
|
||||
<CopyToClipboardIcon text={qrCodeUrl} />
|
||||
</div>
|
||||
<img src={qrCodeUrl} className="qr-code-modal__img" alt="QR code" />
|
||||
<div className="mt-2">{size}x{size}</div>
|
||||
<ForServerVersion minVersion="2.9.0">
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={async () => imageDownloader.saveImage(qrCodeUrl, `${shortCode}-qr-code.${format}`)}
|
||||
>
|
||||
Download <FontAwesomeIcon icon={downloadIcon} className="ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</ForServerVersion>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</Modal>
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { ChangeEvent, FC, useRef } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
import { ChangeEvent, FC } from 'react';
|
||||
import Checkbox from '../../utils/Checkbox';
|
||||
import { InfoTooltip } from '../../utils/InfoTooltip';
|
||||
|
||||
interface ShortUrlFormCheckboxGroupProps {
|
||||
checked?: boolean;
|
||||
|
@ -10,23 +8,6 @@ interface ShortUrlFormCheckboxGroupProps {
|
|||
infoTooltip?: string;
|
||||
}
|
||||
|
||||
const InfoTooltip: FC<{ tooltip: string }> = ({ tooltip }) => {
|
||||
const ref = useRef<HTMLElement | null>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={(el) => {
|
||||
ref.current = el;
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</span>
|
||||
<UncontrolledTooltip target={(() => ref.current) as any} placement="right">{tooltip}</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
||||
{ children, infoTooltip, checked, onChange },
|
||||
) => (
|
||||
|
@ -34,6 +15,6 @@ export const ShortUrlFormCheckboxGroup: FC<ShortUrlFormCheckboxGroupProps> = (
|
|||
<Checkbox inline checked={checked} className={infoTooltip ? 'mr-2' : ''} onChange={onChange}>
|
||||
{children}
|
||||
</Checkbox>
|
||||
{infoTooltip && <InfoTooltip tooltip={infoTooltip} />}
|
||||
{infoTooltip && <InfoTooltip placement="right">{infoTooltip}</InfoTooltip>}
|
||||
</p>
|
||||
);
|
||||
|
|
|
@ -1,39 +1,8 @@
|
|||
@import '../../utils/base';
|
||||
@import '../../utils/mixins/vertical-align';
|
||||
|
||||
.short-urls-row {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__cell.short-urls-row__cell {
|
||||
vertical-align: middle !important;
|
||||
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: .5rem;
|
||||
font-size: .9rem;
|
||||
|
||||
&:before {
|
||||
content: attr(data-th);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
position: absolute;
|
||||
top: 3.5px;
|
||||
right: .5rem;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.short-urls-row__cell--break {
|
||||
|
|
|
@ -51,11 +51,11 @@ const ShortUrlsRow = (
|
|||
}, [ shortUrl.visitsCount ]);
|
||||
|
||||
return (
|
||||
<tr className="short-urls-row">
|
||||
<td className="indivisible short-urls-row__cell" data-th="Created at: ">
|
||||
<tr className="responsive-table__row">
|
||||
<td className="indivisible short-urls-row__cell responsive-table__cell" data-th="Created at">
|
||||
<Time date={shortUrl.dateCreated} />
|
||||
</td>
|
||||
<td className="short-urls-row__cell" data-th="Short URL: ">
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Short URL">
|
||||
<span className="indivisible short-urls-row__cell--relative">
|
||||
<ExternalLink href={shortUrl.shortUrl} />
|
||||
<CopyToClipboardIcon text={shortUrl.shortUrl} onCopy={setCopiedToClipboard} />
|
||||
|
@ -64,16 +64,16 @@ const ShortUrlsRow = (
|
|||
</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}: `}>
|
||||
<td className="responsive-table__cell short-urls-row__cell short-urls-row__cell--break" data-th={`${shortUrl.title ? 'Title' : 'Long URL'}`}>
|
||||
<ExternalLink href={shortUrl.longUrl}>{shortUrl.title ?? shortUrl.longUrl}</ExternalLink>
|
||||
</td>
|
||||
{shortUrl.title && (
|
||||
<td className="short-urls-row__cell short-urls-row__cell--break d-lg-none" data-th="Long URL: ">
|
||||
<td className="short-urls-row__cell responsive-table__cell short-urls-row__cell--break d-lg-none" data-th="Long URL">
|
||||
<ExternalLink href={shortUrl.longUrl} />
|
||||
</td>
|
||||
)}
|
||||
<td className="short-urls-row__cell" data-th="Tags: ">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="short-urls-row__cell text-md-right" data-th="Visits: ">
|
||||
<td className="responsive-table__cell short-urls-row__cell" data-th="Tags">{renderTags(shortUrl.tags)}</td>
|
||||
<td className="responsive-table__cell short-urls-row__cell text-lg-right" data-th="Visits">
|
||||
<ShortUrlVisitsCount
|
||||
visitsCount={shortUrl.visitsCount}
|
||||
shortUrl={shortUrl}
|
||||
|
@ -81,7 +81,7 @@ const ShortUrlsRow = (
|
|||
active={active}
|
||||
/>
|
||||
</td>
|
||||
<td className="short-urls-row__cell">
|
||||
<td className="responsive-table__cell short-urls-row__cell">
|
||||
<ShortUrlsRowMenu selectedServer={selectedServer} shortUrl={shortUrl} />
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
@ -1,13 +1,5 @@
|
|||
@import '../../utils/base';
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-toggle--hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.short-urls-row-menu__dropdown-item--danger.short-urls-row-menu__dropdown-item--danger {
|
||||
color: $dangerColor;
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
import {
|
||||
faChartPie as pieChartIcon,
|
||||
faEllipsisV as menuIcon,
|
||||
faQrcode as qrIcon,
|
||||
faMinusCircle as deleteIcon,
|
||||
faEdit as editIcon,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { ShortUrl, ShortUrlModalProps } from '../data';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
import { DropdownBtnMenu } from '../../utils/DropdownBtnMenu';
|
||||
import ShortUrlDetailLink from './ShortUrlDetailLink';
|
||||
import './ShortUrlsRowMenu.scss';
|
||||
|
||||
|
@ -29,32 +29,27 @@ const ShortUrlsRowMenu = (
|
|||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
|
||||
return (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="short-urls-row-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
<DropdownBtnMenu toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="visits">
|
||||
<FontAwesomeIcon icon={pieChartIcon} fixedWidth /> Visit stats
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
<DropdownItem tag={ShortUrlDetailLink} selectedServer={selectedServer} shortUrl={shortUrl} suffix="edit">
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit short URL
|
||||
</DropdownItem>
|
||||
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
<DropdownItem onClick={toggleQrCode}>
|
||||
<FontAwesomeIcon icon={qrIcon} fixedWidth /> QR code
|
||||
</DropdownItem>
|
||||
<QrCodeModal shortUrl={shortUrl} isOpen={isQrModalOpen} toggle={toggleQrCode} />
|
||||
|
||||
<DropdownItem divider />
|
||||
<DropdownItem divider />
|
||||
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
<DropdownItem className="short-urls-row-menu__dropdown-item--danger" onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Delete short URL
|
||||
</DropdownItem>
|
||||
<DeleteShortUrlModal shortUrl={shortUrl} isOpen={isDeleteModalOpen} toggle={toggleDelete} />
|
||||
</DropdownBtnMenu>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||
import { QrErrorCorrection } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrErrorCorrectionDropdownProps {
|
||||
errorCorrection: QrErrorCorrection;
|
||||
setErrorCorrection: (errorCorrection: QrErrorCorrection) => void;
|
||||
}
|
||||
|
||||
export const QrErrorCorrectionDropdown: FC<QrErrorCorrectionDropdownProps> = (
|
||||
{ errorCorrection, setErrorCorrection },
|
||||
) => (
|
||||
<DropdownBtn text={`Error correction (${errorCorrection})`}>
|
||||
<DropdownItem active={errorCorrection === 'L'} onClick={() => setErrorCorrection('L')}>
|
||||
<b>L</b>ow
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'M'} onClick={() => setErrorCorrection('M')}>
|
||||
<b>M</b>edium
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'Q'} onClick={() => setErrorCorrection('Q')}>
|
||||
<b>Q</b>uartile
|
||||
</DropdownItem>
|
||||
<DropdownItem active={errorCorrection === 'H'} onClick={() => setErrorCorrection('H')}>
|
||||
<b>H</b>igh
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
16
src/short-urls/helpers/qr-codes/QrFormatDropdown.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { DropdownBtn } from '../../../utils/DropdownBtn';
|
||||
import { QrCodeFormat } from '../../../utils/helpers/qrCodes';
|
||||
|
||||
interface QrFormatDropdownProps {
|
||||
format: QrCodeFormat;
|
||||
setFormat: (format: QrCodeFormat) => void;
|
||||
}
|
||||
|
||||
export const QrFormatDropdown: FC<QrFormatDropdownProps> = ({ format, setFormat }) => (
|
||||
<DropdownBtn text={`Format (${format})`}>
|
||||
<DropdownItem active={format === 'png'} onClick={() => setFormat('png')}>PNG</DropdownItem>
|
||||
<DropdownItem active={format === 'svg'} onClick={() => setFormat('svg')}>SVG</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
|
@ -5,6 +5,7 @@ import { buildReducer, buildActionCreator } from '../../utils/helpers/redux';
|
|||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const CREATE_SHORT_URL_START = 'shlink/createShortUrl/CREATE_SHORT_URL_START';
|
||||
|
@ -24,17 +25,13 @@ export interface CreateShortUrlAction extends Action<string> {
|
|||
result: ShortUrl;
|
||||
}
|
||||
|
||||
export interface CreateShortUrlFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlCreation = {
|
||||
result: null,
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & CreateShortUrlFailedAction>({
|
||||
export default buildReducer<ShortUrlCreation, CreateShortUrlAction & ApiErrorAction>({
|
||||
[CREATE_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[CREATE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[CREATE_SHORT_URL]: (_, { result }) => ({ result, saving: false, error: false }),
|
||||
|
@ -53,7 +50,7 @@ export const createShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||
|
||||
dispatch<CreateShortUrlAction>({ type: CREATE_SHORT_URL, result });
|
||||
} catch (e) {
|
||||
dispatch<CreateShortUrlFailedAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: CREATE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ProblemDetailsError } from '../../api/types';
|
|||
import { GetState } from '../../container/types';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_SHORT_URL_START = 'shlink/deleteShortUrl/DELETE_SHORT_URL_START';
|
||||
|
@ -24,17 +25,13 @@ export interface DeleteShortUrlAction extends Action<string> {
|
|||
domain?: string | null;
|
||||
}
|
||||
|
||||
interface DeleteShortUrlErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDeletion = {
|
||||
shortCode: '',
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & DeleteShortUrlErrorAction>({
|
||||
export default buildReducer<ShortUrlDeletion, DeleteShortUrlAction & ApiErrorAction>({
|
||||
[DELETE_SHORT_URL_START]: (state) => ({ ...state, loading: true, error: false }),
|
||||
[DELETE_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, errorData, loading: false, error: true }),
|
||||
[SHORT_URL_DELETED]: (state, { shortCode }) => ({ ...state, shortCode, loading: false, error: false }),
|
||||
|
@ -52,7 +49,7 @@ export const deleteShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
|
|||
await deleteShortUrl(shortCode, domain);
|
||||
dispatch<DeleteShortUrlAction>({ type: SHORT_URL_DELETED, shortCode, domain });
|
||||
} catch (e) {
|
||||
dispatch<DeleteShortUrlErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: DELETE_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import { GetState } from '../../container/types';
|
|||
import { shortUrlMatches } from '../helpers';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const GET_SHORT_URL_DETAIL_START = 'shlink/shortUrlDetail/GET_SHORT_URL_DETAIL_START';
|
||||
|
@ -25,16 +26,12 @@ export interface ShortUrlDetailAction extends Action<string> {
|
|||
shortUrl: ShortUrl;
|
||||
}
|
||||
|
||||
export interface ShortUrlDetailFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlDetail = {
|
||||
loading: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ShortUrlDetailFailedAction>({
|
||||
export default buildReducer<ShortUrlDetail, ShortUrlDetailAction & ApiErrorAction>({
|
||||
[GET_SHORT_URL_DETAIL_START]: () => ({ loading: true, error: false }),
|
||||
[GET_SHORT_URL_DETAIL_ERROR]: (_, { errorData }) => ({ loading: false, error: true, errorData }),
|
||||
[GET_SHORT_URL_DETAIL]: (_, { shortUrl }) => ({ shortUrl, ...initialState }),
|
||||
|
@ -54,6 +51,6 @@ export const getShortUrlDetail = (buildShlinkApiClient: ShlinkApiClientBuilder)
|
|||
|
||||
dispatch<ShortUrlDetailAction>({ shortUrl, type: GET_SHORT_URL_DETAIL });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlDetailFailedAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: GET_SHORT_URL_DETAIL_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
|||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { supportsTagsInPatch } from '../../utils/helpers/features';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_SHORT_URL_START = 'shlink/shortUrlEdition/EDIT_SHORT_URL_START';
|
||||
|
@ -25,16 +26,12 @@ export interface ShortUrlEditedAction extends Action<string> {
|
|||
shortUrl: ShortUrl;
|
||||
}
|
||||
|
||||
export interface ShortUrlEditionFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: ShortUrlEdition = {
|
||||
saving: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ShortUrlEditionFailedAction>({
|
||||
export default buildReducer<ShortUrlEdition, ShortUrlEditedAction & ApiErrorAction>({
|
||||
[EDIT_SHORT_URL_START]: (state) => ({ ...state, saving: true, error: false }),
|
||||
[EDIT_SHORT_URL_ERROR]: (state, { errorData }) => ({ ...state, saving: false, error: true, errorData }),
|
||||
[SHORT_URL_EDITED]: (_, { shortUrl }) => ({ shortUrl, saving: false, error: false }),
|
||||
|
@ -59,7 +56,7 @@ export const editShortUrl = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
|
|||
|
||||
dispatch<ShortUrlEditedAction>({ shortUrl, type: SHORT_URL_EDITED });
|
||||
} catch (e) {
|
||||
dispatch<ShortUrlEditionFailedAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: EDIT_SHORT_URL_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -51,7 +51,7 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('DeleteShortUrlModal', () => DeleteShortUrlModal);
|
||||
bottle.decorator('DeleteShortUrlModal', connect([ 'shortUrlDeletion' ], [ 'deleteShortUrl', 'resetDeleteShortUrl' ]));
|
||||
|
||||
bottle.serviceFactory('QrCodeModal', () => QrCodeModal);
|
||||
bottle.serviceFactory('QrCodeModal', QrCodeModal, 'ImageDownloader', 'ForServerVersion');
|
||||
bottle.decorator('QrCodeModal', connect([ 'selectedServer' ]));
|
||||
|
||||
// Services
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import { Card, CardHeader, CardBody, Button, Collapse } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon, faLink, faEye } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FC } from 'react';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { Versions } from '../utils/helpers/version';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { isServerWithId, SelectedServer } from '../servers/data';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
|
@ -20,17 +19,24 @@ export interface TagCardProps {
|
|||
toggle: () => void;
|
||||
}
|
||||
|
||||
const isTruncated = (el: HTMLElement | undefined): boolean => !!el && el.scrollWidth > el.clientWidth;
|
||||
|
||||
const TagCard = (
|
||||
DeleteTagConfirmModal: FC<TagModalProps>,
|
||||
EditTagModal: FC<TagModalProps>,
|
||||
ForServerVersion: FC<Versions>,
|
||||
colorGenerator: ColorGenerator,
|
||||
) => ({ tag, tagStats, selectedServer, displayed, toggle }: TagCardProps) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
|
||||
const [ hasTitle,, displayTitle ] = useToggle();
|
||||
const titleRef = useRef<HTMLElement>();
|
||||
const serverId = isServerWithId(selectedServer) ? selectedServer.id : '';
|
||||
const shortUrlsLink = `/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isTruncated(titleRef.current)) {
|
||||
displayTitle();
|
||||
}
|
||||
}, [ titleRef.current ]);
|
||||
|
||||
return (
|
||||
<Card className="tag-card">
|
||||
|
@ -41,14 +47,15 @@ const TagCard = (
|
|||
<Button color="link" size="sm" className="tag-card__btn" onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} />
|
||||
</Button>
|
||||
<h5 className="tag-card__tag-title text-ellipsis">
|
||||
<h5
|
||||
className="tag-card__tag-title text-ellipsis"
|
||||
title={hasTitle ? tag : undefined}
|
||||
ref={(el) => {
|
||||
titleRef.current = el ?? undefined;
|
||||
}}
|
||||
>
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} />
|
||||
<ForServerVersion minVersion="2.2.0">
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
</ForServerVersion>
|
||||
<ForServerVersion maxVersion="2.1.*">
|
||||
<Link to={shortUrlsLink}>{tag}</Link>
|
||||
</ForServerVersion>
|
||||
<span className="tag-card__tag-name" onClick={toggle}>{tag}</span>
|
||||
</h5>
|
||||
</CardHeader>
|
||||
|
||||
|
@ -56,7 +63,7 @@ const TagCard = (
|
|||
<Collapse isOpen={displayed}>
|
||||
<CardBody className="tag-card__body">
|
||||
<Link
|
||||
to={shortUrlsLink}
|
||||
to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}
|
||||
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>
|
||||
|
|
33
src/tags/TagsCards.tsx
Normal file
33
src/tags/TagsCards.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { FC, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import { TagCardProps } from './TagCard';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
|
||||
export const TagsCards = (TagCard: FC<TagCardProps>): FC<TagsListChildrenProps> => ({ tagsList, selectedServer }) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<Row>
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import { FC, useEffect, useState } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { Row } from 'reactstrap';
|
||||
import Message from '../utils/Message';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
|
@ -7,33 +7,33 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
|
|||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Topics } from '../mercure/helpers/Topics';
|
||||
import { Settings, TagsMode } from '../settings/reducers/settings';
|
||||
import { TagsList as TagsListState } from './reducers/tagsList';
|
||||
import { TagCardProps } from './TagCard';
|
||||
|
||||
const { ceil } = Math;
|
||||
const TAGS_GROUPS_AMOUNT = 4;
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsModeDropdown } from './TagsModeDropdown';
|
||||
|
||||
export interface TagsListProps {
|
||||
filterTags: (searchTerm: string) => void;
|
||||
forceListTags: Function;
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer }: TagsListProps,
|
||||
const TagsList = (TagsCards: FC<TagsListChildrenProps>, TagsTable: FC<TagsListChildrenProps>) => boundToMercureHub((
|
||||
{ filterTags, forceListTags, tagsList, selectedServer, settings }: TagsListProps,
|
||||
) => {
|
||||
const [ displayedTag, setDisplayedTag ] = useState<string | undefined>();
|
||||
const [ mode, setMode ] = useState<TagsMode>(settings.ui?.tagsMode ?? 'cards');
|
||||
|
||||
useEffect(() => {
|
||||
forceListTags();
|
||||
}, []);
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
if (tagsList.loading) {
|
||||
return <Message loading />;
|
||||
}
|
||||
|
||||
const renderContent = () => {
|
||||
if (tagsList.error) {
|
||||
return (
|
||||
<Result type="error">
|
||||
|
@ -42,40 +42,26 @@ const TagsList = (TagCard: FC<TagCardProps>) => boundToMercureHub((
|
|||
);
|
||||
}
|
||||
|
||||
const tagsCount = tagsList.filteredTags.length;
|
||||
|
||||
if (tagsCount < 1) {
|
||||
if (tagsList.filteredTags.length < 1) {
|
||||
return <Message>No tags found</Message>;
|
||||
}
|
||||
|
||||
const tagsGroups = splitEvery(ceil(tagsCount / TAGS_GROUPS_AMOUNT), tagsList.filteredTags);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
{tagsGroups.map((group, index) => (
|
||||
<div key={index} className="col-md-6 col-xl-3">
|
||||
{group.map((tag) => (
|
||||
<TagCard
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
displayed={displayedTag === tag}
|
||||
toggle={() => setDisplayedTag(displayedTag !== tag ? tag : undefined)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return mode === 'cards'
|
||||
? <TagsCards tagsList={tagsList} selectedServer={selectedServer} />
|
||||
: <TagsTable tagsList={tagsList} selectedServer={selectedServer} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!tagsList.loading && <SearchField className="mb-3" placeholder="Search tags..." onChange={filterTags} />}
|
||||
<SearchField className="mb-3" onChange={filterTags} />
|
||||
<Row className="mb-3">
|
||||
<div className="col-lg-6 offset-lg-6">
|
||||
<TagsModeDropdown mode={mode} onChange={setMode} />
|
||||
</div>
|
||||
</Row>
|
||||
{renderContent()}
|
||||
</>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default TagsList;
|
||||
|
|
23
src/tags/TagsModeDropdown.tsx
Normal file
23
src/tags/TagsModeDropdown.tsx
Normal file
|
@ -0,0 +1,23 @@
|
|||
import { FC } from 'react';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBars as listIcon, faThLarge as cardsIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { DropdownBtn } from '../utils/DropdownBtn';
|
||||
import { TagsMode } from '../settings/reducers/settings';
|
||||
|
||||
interface TagsModeDropdownProps {
|
||||
mode: TagsMode;
|
||||
onChange: (newMode: TagsMode) => void;
|
||||
renderTitle?: (mode: TagsMode) => string;
|
||||
}
|
||||
|
||||
export const TagsModeDropdown: FC<TagsModeDropdownProps> = ({ mode, onChange, renderTitle }) => (
|
||||
<DropdownBtn text={renderTitle?.(mode) ?? `Display mode: ${mode}`}>
|
||||
<DropdownItem active={mode === 'cards'} onClick={() => onChange('cards')}>
|
||||
<FontAwesomeIcon icon={cardsIcon} fixedWidth className="mr-1" /> Cards
|
||||
</DropdownItem>
|
||||
<DropdownItem active={mode === 'list'} onClick={() => onChange('list')}>
|
||||
<FontAwesomeIcon icon={listIcon} fixedWidth className="mr-1" /> List
|
||||
</DropdownItem>
|
||||
</DropdownBtn>
|
||||
);
|
65
src/tags/TagsTable.tsx
Normal file
65
src/tags/TagsTable.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { FC, useEffect, useRef } from 'react';
|
||||
import { splitEvery } from 'ramda';
|
||||
import { RouteChildrenProps } from 'react-router';
|
||||
import { SimpleCard } from '../utils/SimpleCard';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import { useQueryState } from '../utils/helpers/hooks';
|
||||
import { parseQuery } from '../utils/helpers/query';
|
||||
import { TagsListChildrenProps } from './data/TagsListChildrenProps';
|
||||
import { TagsTableRowProps } from './TagsTableRow';
|
||||
|
||||
const TAGS_PER_PAGE = 20; // TODO Allow customizing this value in settings
|
||||
|
||||
export const TagsTable = (colorGenerator: ColorGenerator, TagsTableRow: FC<TagsTableRowProps>) => (
|
||||
{ tagsList, selectedServer, location }: TagsListChildrenProps & RouteChildrenProps,
|
||||
) => {
|
||||
const isFirstLoad = useRef(true);
|
||||
const { page: pageFromQuery = 1 } = parseQuery<{ page?: number | string }>(location.search);
|
||||
const [ page, setPage ] = useQueryState<number>('page', Number(pageFromQuery));
|
||||
const sortedTags = tagsList.filteredTags; // TODO Support sorting tags
|
||||
const pages = splitEvery(TAGS_PER_PAGE, sortedTags);
|
||||
const showPaginator = pages.length > 1;
|
||||
const currentPage = pages[page - 1] ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
!isFirstLoad.current && setPage(1);
|
||||
isFirstLoad.current = false;
|
||||
}, [ tagsList.filteredTags ]);
|
||||
useEffect(() => {
|
||||
scrollTo(0, 0);
|
||||
}, [ page ]);
|
||||
|
||||
return (
|
||||
<SimpleCard key={page} bodyClassName={showPaginator ? 'pb-1' : ''}>
|
||||
<table className="table table-hover mb-0">
|
||||
<thead className="responsive-table__header">
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th className="text-lg-right">Short URLs</th>
|
||||
<th className="text-lg-right">Visits</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPage.length === 0 && <tr><td colSpan={4} className="text-center">No results found</td></tr>}
|
||||
{currentPage.map((tag) => (
|
||||
<TagsTableRow
|
||||
key={tag}
|
||||
tag={tag}
|
||||
tagStats={tagsList.stats[tag]}
|
||||
selectedServer={selectedServer}
|
||||
colorGenerator={colorGenerator}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{showPaginator && (
|
||||
<div className="sticky-card-paginator">
|
||||
<SimplePaginator pagesCount={pages.length} currentPage={page} setCurrentPage={setPage} />
|
||||
</div>
|
||||
)}
|
||||
</SimpleCard>
|
||||
);
|
||||
};
|
59
src/tags/TagsTableRow.tsx
Normal file
59
src/tags/TagsTableRow.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { DropdownItem } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTrash as deleteIcon, faPencilAlt as editIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { getServerId, SelectedServer } from '../servers/data';
|
||||
import ColorGenerator from '../utils/services/ColorGenerator';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { useToggle } from '../utils/helpers/hooks';
|
||||
import { DropdownBtnMenu } from '../utils/DropdownBtnMenu';
|
||||
import TagBullet from './helpers/TagBullet';
|
||||
import { TagModalProps, TagStats } from './data';
|
||||
|
||||
export interface TagsTableRowProps {
|
||||
tag: string;
|
||||
tagStats?: TagStats;
|
||||
selectedServer: SelectedServer;
|
||||
colorGenerator: ColorGenerator;
|
||||
}
|
||||
|
||||
export const TagsTableRow = (DeleteTagConfirmModal: FC<TagModalProps>, EditTagModal: FC<TagModalProps>) => (
|
||||
{ tag, tagStats, colorGenerator, selectedServer }: TagsTableRowProps,
|
||||
) => {
|
||||
const [ isDeleteModalOpen, toggleDelete ] = useToggle();
|
||||
const [ isEditModalOpen, toggleEdit ] = useToggle();
|
||||
const [ isDropdownOpen, toggleDropdown ] = useToggle();
|
||||
const serverId = getServerId(selectedServer);
|
||||
|
||||
return (
|
||||
<tr className="responsive-table__row">
|
||||
<th className="responsive-table__cell" data-th="Tag">
|
||||
<TagBullet tag={tag} colorGenerator={colorGenerator} /> {tag}
|
||||
</th>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Short URLs">
|
||||
<Link to={`/server/${serverId}/list-short-urls/1?tag=${encodeURIComponent(tag)}`}>
|
||||
{prettify(tagStats?.shortUrlsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right" data-th="Visits">
|
||||
<Link to={`/server/${serverId}/tag/${tag}/visits`}>
|
||||
{prettify(tagStats?.visitsCount ?? 0)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="responsive-table__cell text-lg-right">
|
||||
<DropdownBtnMenu toggle={toggleDropdown} isOpen={isDropdownOpen}>
|
||||
<DropdownItem onClick={toggleEdit}>
|
||||
<FontAwesomeIcon icon={editIcon} fixedWidth className="mr-1" /> Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={toggleDelete}>
|
||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth className="mr-1" /> Delete
|
||||
</DropdownItem>
|
||||
</DropdownBtnMenu>
|
||||
</td>
|
||||
|
||||
<EditTagModal tag={tag} toggle={toggleEdit} isOpen={isEditModalOpen} />
|
||||
<DeleteTagConfirmModal tag={tag} toggle={toggleDelete} isOpen={isDeleteModalOpen} />
|
||||
</tr>
|
||||
);
|
||||
};
|
7
src/tags/data/TagsListChildrenProps.ts
Normal file
7
src/tags/data/TagsListChildrenProps.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { TagsList as TagsListState } from '../reducers/tagsList';
|
||||
import { SelectedServer } from '../../servers/data';
|
||||
|
||||
export interface TagsListChildrenProps {
|
||||
tagsList: TagsListState;
|
||||
selectedServer: SelectedServer;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from 'react';
|
||||
import { Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { Button, Input, Modal, ModalBody, ModalFooter, ModalHeader, Popover } from 'reactstrap';
|
||||
import { ChromePicker } from 'react-color';
|
||||
import { faPalette as colorIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
@ -25,10 +25,12 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
const [ color, setColor ] = useState(getColorForKey(tag));
|
||||
const [ showColorPicker, toggleColorPicker, , hideColorPicker ] = useToggle();
|
||||
const { editing, error, errorData } = tagEdit;
|
||||
const saveTag = handleEventPreventingDefault(async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}));
|
||||
const saveTag = handleEventPreventingDefault(
|
||||
async () => editTag(tag, newTagName, color)
|
||||
.then(() => tagEdited(tag, newTagName, color))
|
||||
.then(toggle)
|
||||
.catch(() => {}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={hideColorPicker}>
|
||||
|
@ -47,13 +49,11 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
<Popover isOpen={showColorPicker} toggle={toggleColorPicker} target="colorPickerBtn" placement="right">
|
||||
<ChromePicker color={color} disableAlpha onChange={({ hex }) => setColor(hex)} />
|
||||
</Popover>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
value={newTagName}
|
||||
placeholder="Tag"
|
||||
required
|
||||
className="form-control"
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
onChange={({ target }) => setNewTagName(target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
@ -64,8 +64,8 @@ const EditTagModal = ({ getColorForKey }: ColorGenerator) => (
|
|||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<button type="button" className="btn btn-link" onClick={toggle}>Cancel</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</button>
|
||||
<Button type="button" color="link" onClick={toggle}>Cancel</Button>
|
||||
<Button color="primary" disabled={editing}>{editing ? 'Saving...' : 'Save'}</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect } from 'react';
|
||||
import ReactTags, { SuggestionComponentProps, TagComponentProps } from 'react-tag-autocomplete';
|
||||
import ColorGenerator from '../../utils/services/ColorGenerator';
|
||||
import { Settings } from '../../settings/reducers/settings';
|
||||
import { TagsList } from '../reducers/tagsList';
|
||||
import TagBullet from './TagBullet';
|
||||
import Tag from './Tag';
|
||||
|
@ -14,17 +15,19 @@ export interface TagsSelectorProps {
|
|||
interface TagsSelectorConnectProps extends TagsSelectorProps {
|
||||
listTags: Function;
|
||||
tagsList: TagsList;
|
||||
settings: Settings;
|
||||
}
|
||||
|
||||
const toComponentTag = (tag: string) => ({ id: tag, name: tag });
|
||||
|
||||
const TagsSelector = (colorGenerator: ColorGenerator) => (
|
||||
{ selectedTags, onChange, listTags, tagsList, placeholder = 'Add tags to the URL' }: TagsSelectorConnectProps,
|
||||
{ selectedTags, onChange, placeholder, listTags, tagsList, settings }: TagsSelectorConnectProps,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
listTags();
|
||||
}, []);
|
||||
|
||||
const searchMode = settings.shortUrlCreation?.tagFilteringMode ?? 'startsWith';
|
||||
const ReactTagsTag = ({ tag, onDelete }: TagComponentProps) =>
|
||||
<Tag colorGenerator={colorGenerator} text={tag.name} clearable className="react-tags__tag" onClose={onDelete} />;
|
||||
const ReactTagsSuggestion = ({ item }: SuggestionComponentProps) => (
|
||||
|
@ -42,15 +45,25 @@ const TagsSelector = (colorGenerator: ColorGenerator) => (
|
|||
suggestionComponent={ReactTagsSuggestion}
|
||||
allowNew
|
||||
addOnBlur
|
||||
placeholderText={placeholder}
|
||||
placeholderText={placeholder ?? 'Add tags to the URL'}
|
||||
minQueryLength={1}
|
||||
delimiters={[ 'Enter', 'Tab', ',' ]}
|
||||
suggestionsTransform={
|
||||
searchMode === 'includes'
|
||||
? (query, suggestions) => suggestions.filter(({ name }) => name.includes(query))
|
||||
: undefined
|
||||
}
|
||||
onDelete={(removedTagIndex) => {
|
||||
const tagsCopy = [ ...selectedTags ];
|
||||
|
||||
tagsCopy.splice(removedTagIndex, 1);
|
||||
onChange(tagsCopy);
|
||||
}}
|
||||
onAddition={({ name: newTag }) => onChange([ ...selectedTags, newTag.toLowerCase() ])}
|
||||
onAddition={({ name: newTag }) => onChange(
|
||||
// * Avoid duplicated tags (thanks to the Set),
|
||||
// * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once.
|
||||
[ ...new Set([ ...selectedTags, ...newTag.toLowerCase().split(',') ]) ],
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -4,6 +4,7 @@ import { GetState } from '../../container/types';
|
|||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const DELETE_TAG_START = 'shlink/deleteTag/DELETE_TAG_START';
|
||||
|
@ -22,16 +23,12 @@ export interface DeleteTagAction extends Action<string> {
|
|||
tag: string;
|
||||
}
|
||||
|
||||
export interface DeleteTagFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagDeletion = {
|
||||
deleting: false,
|
||||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagDeletion, DeleteTagFailedAction>({
|
||||
export default buildReducer<TagDeletion, ApiErrorAction>({
|
||||
[DELETE_TAG_START]: () => ({ deleting: true, error: false }),
|
||||
[DELETE_TAG_ERROR]: (_, { errorData }) => ({ deleting: false, error: true, errorData }),
|
||||
[DELETE_TAG]: () => ({ deleting: false, error: false }),
|
||||
|
@ -48,7 +45,7 @@ export const deleteTag = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag:
|
|||
await deleteTags([ tag ]);
|
||||
dispatch({ type: DELETE_TAG });
|
||||
} catch (e) {
|
||||
dispatch<DeleteTagFailedAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: DELETE_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import ColorGenerator from '../../utils/services/ColorGenerator';
|
|||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { ProblemDetailsError } from '../../api/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
/* eslint-disable padding-line-between-statements */
|
||||
export const EDIT_TAG_START = 'shlink/editTag/EDIT_TAG_START';
|
||||
|
@ -29,10 +30,6 @@ export interface EditTagAction extends Action<string> {
|
|||
color: string;
|
||||
}
|
||||
|
||||
export interface EditTagFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
const initialState: TagEdition = {
|
||||
oldName: '',
|
||||
newName: '',
|
||||
|
@ -40,7 +37,7 @@ const initialState: TagEdition = {
|
|||
error: false,
|
||||
};
|
||||
|
||||
export default buildReducer<TagEdition, EditTagAction & EditTagFailedAction>({
|
||||
export default buildReducer<TagEdition, EditTagAction & ApiErrorAction>({
|
||||
[EDIT_TAG_START]: (state) => ({ ...state, editing: true, error: false }),
|
||||
[EDIT_TAG_ERROR]: (state, { errorData }) => ({ ...state, editing: false, error: true, errorData }),
|
||||
[EDIT_TAG]: (_, action) => ({
|
||||
|
@ -63,7 +60,7 @@ export const editTag = (buildShlinkApiClient: ShlinkApiClientBuilder, colorGener
|
|||
colorGenerator.setColorForKey(newName, color);
|
||||
dispatch({ type: EDIT_TAG, oldName, newName });
|
||||
} catch (e) {
|
||||
dispatch<EditTagFailedAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: EDIT_TAG_ERROR, errorData: parseApiError(e) });
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilde
|
|||
import { CreateVisit, Stats } from '../../visits/types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { TagStats } from '../data';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { DeleteTagAction, TAG_DELETED } from './tagDelete';
|
||||
import { EditTagAction, TAG_EDITED } from './tagEdit';
|
||||
|
||||
|
@ -34,20 +35,16 @@ interface ListTagsAction extends Action<string> {
|
|||
stats: TagsStatsMap;
|
||||
}
|
||||
|
||||
interface ListTagsFailedAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
||||
|
||||
interface FilterTagsAction extends Action<string> {
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
type ListTagsCombinedAction = ListTagsAction
|
||||
type TagsCombinedAction = ListTagsAction
|
||||
& DeleteTagAction
|
||||
& CreateVisitsAction
|
||||
& EditTagAction
|
||||
& FilterTagsAction
|
||||
& ListTagsFailedAction;
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState = {
|
||||
tags: [],
|
||||
|
@ -83,7 +80,7 @@ const calculateVisitsPerTag = (createdVisits: CreateVisit[]): TagIncrease[] => O
|
|||
}, {}),
|
||||
);
|
||||
|
||||
export default buildReducer<TagsList, ListTagsCombinedAction>({
|
||||
export default buildReducer<TagsList, TagsCombinedAction>({
|
||||
[LIST_TAGS_START]: () => ({ ...initialState, loading: true }),
|
||||
[LIST_TAGS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
|
||||
[LIST_TAGS]: (_, { tags, stats }) => ({ ...initialState, stats, tags, filteredTags: tags }),
|
||||
|
@ -130,7 +127,7 @@ export const listTags = (buildShlinkApiClient: ShlinkApiClientBuilder, force = t
|
|||
|
||||
dispatch<ListTagsAction>({ tags, stats: processedStats, type: LIST_TAGS });
|
||||
} catch (e) {
|
||||
dispatch<ListTagsFailedAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: LIST_TAGS_ERROR, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Bottle, { IContainer } from 'bottlejs';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import TagsSelector from '../helpers/TagsSelector';
|
||||
import TagCard from '../TagCard';
|
||||
import DeleteTagConfirmModal from '../helpers/DeleteTagConfirmModal';
|
||||
|
@ -8,20 +9,16 @@ import { filterTags, listTags } from '../reducers/tagsList';
|
|||
import { deleteTag, tagDeleted } from '../reducers/tagDelete';
|
||||
import { editTag, tagEdited } from '../reducers/tagEdit';
|
||||
import { ConnectDecorator } from '../../container/types';
|
||||
import { TagsCards } from '../TagsCards';
|
||||
import { TagsTable } from '../TagsTable';
|
||||
import { TagsTableRow } from '../TagsTableRow';
|
||||
|
||||
const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
||||
// Components
|
||||
bottle.serviceFactory('TagsSelector', TagsSelector, 'ColorGenerator');
|
||||
bottle.decorator('TagsSelector', connect([ 'tagsList' ], [ 'listTags' ]));
|
||||
bottle.decorator('TagsSelector', connect([ 'tagsList', 'settings' ], [ 'listTags' ]));
|
||||
|
||||
bottle.serviceFactory(
|
||||
'TagCard',
|
||||
TagCard,
|
||||
'DeleteTagConfirmModal',
|
||||
'EditTagModal',
|
||||
'ForServerVersion',
|
||||
'ColorGenerator',
|
||||
);
|
||||
bottle.serviceFactory('TagCard', TagCard, 'DeleteTagConfirmModal', 'EditTagModal', 'ColorGenerator');
|
||||
|
||||
bottle.serviceFactory('DeleteTagConfirmModal', () => DeleteTagConfirmModal);
|
||||
bottle.decorator('DeleteTagConfirmModal', connect([ 'tagDelete' ], [ 'deleteTag', 'tagDeleted' ]));
|
||||
|
@ -29,9 +26,15 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
|
|||
bottle.serviceFactory('EditTagModal', EditTagModal, 'ColorGenerator');
|
||||
bottle.decorator('EditTagModal', connect([ 'tagEdit' ], [ 'editTag', 'tagEdited' ]));
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagCard');
|
||||
bottle.serviceFactory('TagsCards', TagsCards, 'TagCard');
|
||||
bottle.serviceFactory('TagsTableRow', TagsTableRow, 'DeleteTagConfirmModal', 'EditTagModal');
|
||||
|
||||
bottle.serviceFactory('TagsTable', TagsTable, 'ColorGenerator', 'TagsTableRow');
|
||||
bottle.decorator('TagsTable', withRouter);
|
||||
|
||||
bottle.serviceFactory('TagsList', TagsList, 'TagsCards', 'TagsTable');
|
||||
bottle.decorator('TagsList', connect(
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo' ],
|
||||
[ 'tagsList', 'selectedServer', 'mercureInfo', 'settings' ],
|
||||
[ 'forceListTags', 'filterTags', 'createNewVisits', 'loadMercureInfo' ],
|
||||
));
|
||||
|
||||
|
|
3
src/utils/DropdownBtnMenu.scss
Normal file
3
src/utils/DropdownBtnMenu.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.dropdown-btn-menu__dropdown-toggle:after {
|
||||
display: none !important;
|
||||
}
|
20
src/utils/DropdownBtnMenu.tsx
Normal file
20
src/utils/DropdownBtnMenu.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { FC } from 'react';
|
||||
import { ButtonDropdown, DropdownMenu, DropdownToggle } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisV as menuIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import './DropdownBtnMenu.scss';
|
||||
|
||||
export interface DropdownBtnMenuProps {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
right?: boolean;
|
||||
}
|
||||
|
||||
export const DropdownBtnMenu: FC<DropdownBtnMenuProps> = ({ isOpen, toggle, children, right = true }) => (
|
||||
<ButtonDropdown toggle={toggle} isOpen={isOpen}>
|
||||
<DropdownToggle size="sm" caret outline className="dropdown-btn-menu__dropdown-toggle">
|
||||
<FontAwesomeIcon icon={menuIcon} />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right={right}>{children}</DropdownMenu>
|
||||
</ButtonDropdown>
|
||||
);
|
|
@ -1,29 +1,37 @@
|
|||
import { FC } from 'react';
|
||||
import { FC, useRef } from 'react';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { InputType } from 'reactstrap/lib/Input';
|
||||
|
||||
interface FormGroupContainerProps {
|
||||
export interface FormGroupContainerProps {
|
||||
value: string;
|
||||
onChange: (newValue: string) => void;
|
||||
id?: string;
|
||||
type?: InputType;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
export const FormGroupContainer: FC<FormGroupContainerProps> = (
|
||||
{ children, value, onChange, id = uuid(), type = 'text', required = true },
|
||||
) => (
|
||||
<div className="form-group">
|
||||
<label htmlFor={id} className="create-server__label">
|
||||
{children}:
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type={type}
|
||||
id={id}
|
||||
value={value}
|
||||
required={required}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
{ children, value, onChange, id, type, required, placeholder, className, labelClassName },
|
||||
) => {
|
||||
const forId = useRef<string>(id ?? uuid());
|
||||
|
||||
return (
|
||||
<div className={`form-group ${className ?? ''}`}>
|
||||
<label htmlFor={forId.current} className={labelClassName ?? ''}>
|
||||
{children}:
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
type={type ?? 'text'}
|
||||
id={forId.current}
|
||||
value={value}
|
||||
required={required ?? true}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
26
src/utils/InfoTooltip.tsx
Normal file
26
src/utils/InfoTooltip.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { FC, useRef } from 'react';
|
||||
import * as Popper from 'popper.js';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faInfoCircle as infoIcon } from '@fortawesome/free-solid-svg-icons';
|
||||
import { UncontrolledTooltip } from 'reactstrap';
|
||||
|
||||
interface InfoTooltipProps {
|
||||
className?: string;
|
||||
placement: Popper.Placement;
|
||||
}
|
||||
|
||||
export const InfoTooltip: FC<InfoTooltipProps> = ({ className = '', placement, children }) => {
|
||||
const ref = useRef<HTMLSpanElement | null>();
|
||||
const refCallback = (el: HTMLSpanElement) => {
|
||||
ref.current = el;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className={className} ref={refCallback}>
|
||||
<FontAwesomeIcon icon={infoIcon} />
|
||||
</span>
|
||||
<UncontrolledTooltip target={(() => ref.current) as any} placement={placement}>{children}</UncontrolledTooltip>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -10,14 +10,11 @@ let timer: NodeJS.Timeout | null;
|
|||
interface SearchFieldProps {
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
large?: boolean;
|
||||
noBorder?: boolean;
|
||||
}
|
||||
|
||||
const SearchField = (
|
||||
{ onChange, className, placeholder = 'Search...', large = true, noBorder = false }: SearchFieldProps,
|
||||
) => {
|
||||
const SearchField = ({ onChange, className, large = true, noBorder = false }: SearchFieldProps) => {
|
||||
const [ searchTerm, setSearchTerm ] = useState('');
|
||||
|
||||
const resetTimer = () => {
|
||||
|
@ -43,7 +40,7 @@ const SearchField = (
|
|||
'form-control-lg': large,
|
||||
'search-field__input--no-border': noBorder,
|
||||
})}
|
||||
placeholder={placeholder}
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => searchTermChanged(e.target.value)}
|
||||
/>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.short-urls-paginator {
|
||||
.sticky-card-paginator {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background-color: var(--primary-color-alfa);
|
|
@ -1,30 +1,18 @@
|
|||
import { ChangeEvent, FC } from 'react';
|
||||
import { ChartData, ChartTooltipItem } from 'chart.js';
|
||||
import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js';
|
||||
import { prettify } from './numbers';
|
||||
|
||||
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
export const pointerOnHover = ({ native }: ChartEvent, [ firstElement ]: ActiveElement[]) => {
|
||||
if (!native?.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = native.target as HTMLCanvasElement;
|
||||
|
||||
canvas.style.cursor = firstElement ? 'pointer' : 'default';
|
||||
};
|
||||
|
||||
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
|
||||
item: ChartTooltipItem,
|
||||
{ datasets }: ChartData,
|
||||
) => {
|
||||
const { datasetIndex } = item;
|
||||
const value = item[labelToPick];
|
||||
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
|
||||
export const renderChartLabel = ({ dataset, formattedValue }: TooltipItem<ChartType>) =>
|
||||
`${dataset.label}: ${prettify(formattedValue)}`;
|
||||
|
||||
return `${datasetLabel}: ${prettify(Number(value))}`;
|
||||
};
|
||||
|
||||
export const renderDoughnutChartLabel = (
|
||||
{ datasetIndex, index }: ChartTooltipItem,
|
||||
{ labels, datasets }: ChartData,
|
||||
) => {
|
||||
const datasetLabel = index !== undefined && labels?.[index] || '';
|
||||
const value = datasetIndex !== undefined && index !== undefined
|
||||
&& datasets?.[datasetIndex]?.data?.[index]
|
||||
|| '';
|
||||
|
||||
return `${datasetLabel}: ${prettify(Number(value))}`; // eslint-disable-line @typescript-eslint/no-base-to-string
|
||||
};
|
||||
export const renderPieChartLabel = ({ label, formattedValue }: TooltipItem<ChartType>) =>
|
||||
`${label}: ${prettify(formattedValue)}`;
|
||||
|
|
|
@ -4,16 +4,6 @@ import { versionMatch, Versions } from './version';
|
|||
const serverMatchesVersions = (versions: Versions) => (selectedServer: SelectedServer): boolean =>
|
||||
isReachableServer(selectedServer) && versionMatch(selectedServer.version, versions);
|
||||
|
||||
export const supportsSettingShortCodeLength = serverMatchesVersions({ minVersion: '2.1.0' });
|
||||
|
||||
export const supportsTagVisits = serverMatchesVersions({ minVersion: '2.2.0' });
|
||||
|
||||
export const supportsListingDomains = serverMatchesVersions({ minVersion: '2.4.0' });
|
||||
|
||||
export const supportsQrCodeSvgFormat = supportsListingDomains;
|
||||
|
||||
export const supportsValidateUrl = supportsListingDomains;
|
||||
|
||||
export const supportsQrCodeSizeInQuery = serverMatchesVersions({ minVersion: '2.5.0' });
|
||||
|
||||
export const supportsShortUrlTitle = serverMatchesVersions({ minVersion: '2.6.0' });
|
||||
|
@ -27,3 +17,7 @@ export const supportsTagsInPatch = supportsShortUrlTitle;
|
|||
export const supportsBotVisits = serverMatchesVersions({ minVersion: '2.7.0' });
|
||||
|
||||
export const supportsCrawlableVisits = supportsBotVisits;
|
||||
|
||||
export const supportsQrErrorCorrection = serverMatchesVersions({ minVersion: '2.8.0' });
|
||||
|
||||
export const supportsDomainRedirects = supportsQrErrorCorrection;
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
export const saveCsv = ({ document }: Window, csv: string, filename: string) => {
|
||||
export const saveUrl = ({ document }: Window, url: string, filename: string) => {
|
||||
const link = document.createElement('a');
|
||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
|
@ -10,3 +8,10 @@ export const saveCsv = ({ document }: Window, csv: string, filename: string) =>
|
|||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
export const saveCsv = (window: Window, csv: string, filename: string) => {
|
||||
const blob = new Blob([ csv ], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
saveUrl(window, url, filename);
|
||||
};
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useRef } from 'react';
|
||||
import { useSwipeable as useReactSwipeable } from 'react-swipeable';
|
||||
import { parseQuery, stringifyQuery } from './query';
|
||||
|
||||
const DEFAULT_DELAY = 2000;
|
||||
|
||||
|
@ -51,3 +52,17 @@ export const useSwipeable = (showSidebar: () => void, hideSidebar: () => void) =
|
|||
onSwipedRight: swipeMenuIfNoModalExists(showSidebar),
|
||||
});
|
||||
};
|
||||
|
||||
export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newValue: T) => void ] => {
|
||||
const [ value, setValue ] = useState(initialState);
|
||||
const setValueWithLocation = (value: T) => {
|
||||
const { location, history } = window;
|
||||
const query = parseQuery<any>(location.search);
|
||||
|
||||
query[paramName] = value;
|
||||
history.pushState(null, '', `${location.pathname}?${stringifyQuery(query)}`);
|
||||
setValue(value);
|
||||
};
|
||||
|
||||
return [ value, setValueWithLocation ];
|
||||
};
|
||||
|
|
|
@ -2,6 +2,6 @@ const TEN_ROUNDING_NUMBER = 10;
|
|||
const { ceil } = Math;
|
||||
const formatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
export const prettify = (number: number) => formatter.format(number);
|
||||
export const prettify = (number: number | string) => formatter.format(Number(number));
|
||||
|
||||
export const roundTen = (number: number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
|
||||
|
|
|
@ -3,28 +3,32 @@ import { stringifyQuery } from './query';
|
|||
|
||||
export interface QrCodeCapabilities {
|
||||
useSizeInPath: boolean;
|
||||
svgIsSupported: boolean;
|
||||
marginIsSupported: boolean;
|
||||
errorCorrectionIsSupported: boolean;
|
||||
}
|
||||
|
||||
export type QrCodeFormat = 'svg' | 'png';
|
||||
|
||||
export type QrErrorCorrection = 'L' | 'M' | 'Q' | 'H';
|
||||
|
||||
export interface QrCodeOptions {
|
||||
size: number;
|
||||
format: QrCodeFormat;
|
||||
margin: number;
|
||||
errorCorrection: QrErrorCorrection;
|
||||
}
|
||||
|
||||
export const buildQrCodeUrl = (
|
||||
shortUrl: string,
|
||||
{ size, format, margin }: QrCodeOptions,
|
||||
{ useSizeInPath, svgIsSupported, marginIsSupported }: QrCodeCapabilities,
|
||||
{ size, format, margin, errorCorrection }: QrCodeOptions,
|
||||
{ useSizeInPath, marginIsSupported, errorCorrectionIsSupported }: QrCodeCapabilities,
|
||||
): string => {
|
||||
const baseUrl = `${shortUrl}/qr-code${useSizeInPath ? `/${size}` : ''}`;
|
||||
const query = stringifyQuery({
|
||||
size: useSizeInPath ? undefined : size,
|
||||
format: svgIsSupported ? format : undefined,
|
||||
format,
|
||||
margin: marginIsSupported && margin > 0 ? margin : undefined,
|
||||
errorCorrection: errorCorrectionIsSupported ? errorCorrection : undefined,
|
||||
});
|
||||
|
||||
return `${baseUrl}${isEmpty(query) ? '' : `?${query}`}`;
|
||||
|
|
42
src/utils/table/ResponsiveTable.scss
Normal file
42
src/utils/table/ResponsiveTable.scss
Normal file
|
@ -0,0 +1,42 @@
|
|||
@import '../../utils/base';
|
||||
|
||||
.responsive-table__header {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.responsive-table__row {
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.responsive-table__cell.responsive-table__cell {
|
||||
vertical-align: middle !important;
|
||||
|
||||
@media (max-width: $responsiveTableBreakpoint) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: .5rem;
|
||||
font-size: .9rem;
|
||||
|
||||
&[data-th]:before {
|
||||
content: attr(data-th) ': ';
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
position: absolute;
|
||||
top: 3.5px;
|
||||
right: .5rem;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -43,3 +43,7 @@ export type OptionalString = Optional<string>;
|
|||
export type RecursivePartial<T> = {
|
||||
[P in keyof T]?: RecursivePartial<T[P]>;
|
||||
};
|
||||
|
||||
export const nonEmptyValueOrNull = <T>(value: T): T | null => isEmpty(value) ? null : value;
|
||||
|
||||
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
|
||||
|
|
|
@ -41,4 +41,4 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
|||
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [ Topics.orphanVisits() ]);
|
||||
}, () => [ Topics.orphanVisits ]);
|
||||
|
|
|
@ -43,6 +43,6 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
|
|||
<TagVisitsHeader tagVisits={tagVisits} goBack={goBack} colorGenerator={colorGenerator} />
|
||||
</VisitsStats>
|
||||
);
|
||||
}, () => [ Topics.visits() ]);
|
||||
}, () => [ Topics.visits ]);
|
||||
|
||||
export default TagVisits;
|
||||
|
|
|
@ -15,15 +15,15 @@ import { ShlinkApiError } from '../api/ShlinkApiError';
|
|||
import { Settings } from '../settings/reducers/settings';
|
||||
import { SelectedServer } from '../servers/data';
|
||||
import { supportsBotVisits } from '../utils/helpers/features';
|
||||
import SortableBarGraph from './helpers/SortableBarGraph';
|
||||
import GraphCard from './helpers/GraphCard';
|
||||
import LineChartCard from './helpers/LineChartCard';
|
||||
import LineChartCard from './charts/LineChartCard';
|
||||
import VisitsTable from './VisitsTable';
|
||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types';
|
||||
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
||||
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||
import './VisitsStats.scss';
|
||||
|
||||
export interface VisitsStatsProps {
|
||||
|
@ -173,13 +173,13 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
|
||||
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||
<GraphCard title="Operating systems" stats={os} />
|
||||
<DoughnutChartCard title="Operating systems" stats={os} />
|
||||
</div>
|
||||
<div className={classNames('mt-3 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||
<GraphCard title="Browsers" stats={browsers} />
|
||||
<DoughnutChartCard title="Browsers" stats={browsers} />
|
||||
</div>
|
||||
<div className={classNames('mt-3', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
||||
<SortableBarGraph
|
||||
<SortableBarChartCard
|
||||
title="Referrers"
|
||||
stats={referrers}
|
||||
withPagination={false}
|
||||
|
@ -194,7 +194,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
</div>
|
||||
{isOrphanVisits && (
|
||||
<div className="mt-3 col-lg-6">
|
||||
<SortableBarGraph
|
||||
<SortableBarChartCard
|
||||
title="Visited URLs"
|
||||
stats={visitedUrls}
|
||||
highlightedLabel={highlightedLabel}
|
||||
|
@ -211,7 +211,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
|
||||
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
||||
<div className="col-lg-6 mt-3">
|
||||
<SortableBarGraph
|
||||
<SortableBarChartCard
|
||||
title="Countries"
|
||||
stats={countries}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'country')}
|
||||
|
@ -224,7 +224,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<div className="col-lg-6 mt-3">
|
||||
<SortableBarGraph
|
||||
<SortableBarChartCard
|
||||
title="Cities"
|
||||
stats={cities}
|
||||
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'city')}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.graph-card__footer--sticky {
|
||||
.chart-card__footer--sticky {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
}
|
16
src/visits/charts/ChartCard.tsx
Normal file
16
src/visits/charts/ChartCard.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||
import { FC, ReactNode } from 'react';
|
||||
import './ChartCard.scss';
|
||||
|
||||
interface ChartCardProps {
|
||||
title: Function | string;
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
export const ChartCard: FC<ChartCardProps> = ({ title, footer, children }) => (
|
||||
<Card>
|
||||
<CardHeader className="chart-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>{children}</CardBody>
|
||||
{footer && <CardFooter className="chart-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
72
src/visits/charts/DoughnutChart.tsx
Normal file
72
src/visits/charts/DoughnutChart.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { FC, useState, memo } from 'react';
|
||||
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { renderPieChartLabel } from '../../utils/helpers/charts';
|
||||
import { isDarkThemeEnabled, PRIMARY_DARK_COLOR, PRIMARY_LIGHT_COLOR } from '../../utils/theme';
|
||||
import { Stats } from '../types';
|
||||
import { DoughnutChartLegend } from './DoughnutChartLegend';
|
||||
|
||||
interface DoughnutChartProps {
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
const generateChartDatasets = (data: number[]): ChartDataset[] => [
|
||||
{
|
||||
data,
|
||||
backgroundColor: [
|
||||
'#97BBCD',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#57A773',
|
||||
'#414066',
|
||||
'#08B2E3',
|
||||
'#B6C454',
|
||||
'#DCDCDC',
|
||||
'#463730',
|
||||
],
|
||||
borderColor: isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
||||
borderWidth: 2,
|
||||
},
|
||||
];
|
||||
const generateChartData = (labels: string[], data: number[]): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data),
|
||||
});
|
||||
|
||||
export const DoughnutChart: FC<DoughnutChartProps> = memo(({ stats }) => {
|
||||
const [ chartRef, setChartRef ] = useState<Chart | undefined>(); // Cannot use useRef here
|
||||
const labels = keys(stats);
|
||||
const data = values(stats);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: true,
|
||||
callbacks: { label: renderPieChartLabel },
|
||||
},
|
||||
},
|
||||
};
|
||||
const chartData = generateChartData(labels, data);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 col-md-7">
|
||||
<Doughnut
|
||||
height={300}
|
||||
data={chartData}
|
||||
options={options}
|
||||
ref={(element) => {
|
||||
setChartRef(element ?? undefined);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-sm-12 col-md-5">
|
||||
{chartRef && <DoughnutChartLegend chart={chartRef} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
15
src/visits/charts/DoughnutChartCard.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { FC } from 'react';
|
||||
import { Stats } from '../types';
|
||||
import { DoughnutChart } from './DoughnutChart';
|
||||
import { ChartCard } from './ChartCard';
|
||||
|
||||
interface DoughnutChartCardProps {
|
||||
title: string;
|
||||
stats: Stats;
|
||||
}
|
||||
|
||||
export const DoughnutChartCard: FC<DoughnutChartCardProps> = ({ title, stats }) => (
|
||||
<ChartCard title={title}>
|
||||
<DoughnutChart stats={stats} />
|
||||
</ChartCard>
|
||||
);
|
|
@ -1,6 +1,6 @@
|
|||
@import '../../utils/base';
|
||||
|
||||
.default-chart__pie-chart-legend {
|
||||
.doughnut-chart-legend {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
@ -10,11 +10,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item:not(:first-child) {
|
||||
.doughnut-chart-legend__item:not(:first-child) {
|
||||
margin-top: .3rem;
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item-color {
|
||||
.doughnut-chart-legend__item-color {
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
|
@ -22,7 +22,7 @@
|
|||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.default-chart__pie-chart-legend-item-text {
|
||||
.doughnut-chart-legend__item-text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
28
src/visits/charts/DoughnutChartLegend.tsx
Normal file
28
src/visits/charts/DoughnutChartLegend.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import { FC } from 'react';
|
||||
import { Chart } from 'chart.js';
|
||||
import './DoughnutChartLegend.scss';
|
||||
|
||||
interface DoughnutChartLegendProps {
|
||||
chart: Chart;
|
||||
}
|
||||
|
||||
export const DoughnutChartLegend: FC<DoughnutChartLegendProps> = ({ chart }) => {
|
||||
const { config } = chart;
|
||||
const { labels = [], datasets = [] } = config.data ?? {};
|
||||
const [{ backgroundColor: colors }] = datasets;
|
||||
const { defaultColor } = config.options ?? {} as any;
|
||||
|
||||
return (
|
||||
<ul className="doughnut-chart-legend">
|
||||
{(labels as string[]).map((label, index) => (
|
||||
<li key={label} className="doughnut-chart-legend__item d-flex">
|
||||
<div
|
||||
className="doughnut-chart-legend__item-color"
|
||||
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
|
||||
/>
|
||||
<small className="doughnut-chart-legend__item-text flex-fill">{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
131
src/visits/charts/HorizontalBarChart.tsx
Normal file
|
@ -0,0 +1,131 @@
|
|||
import { FC } from 'react';
|
||||
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { keys, values } from 'ramda';
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { Stats } from '../types';
|
||||
import { HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR_ALPHA, MAIN_COLOR, MAIN_COLOR_ALPHA } from '../../utils/theme';
|
||||
|
||||
export interface HorizontalBarChartProps {
|
||||
stats: Stats;
|
||||
max?: number;
|
||||
highlightedStats?: Stats;
|
||||
highlightedLabel?: string;
|
||||
onClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
|
||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
||||
const determineHeight = (labels: string[]): number | undefined => labels.length > 20 ? labels.length * 10 : undefined;
|
||||
|
||||
const generateChartDatasets = (
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartDataset[] => {
|
||||
const mainDataset: ChartDataset = {
|
||||
data,
|
||||
label: highlightedLabel ? 'Non-selected' : 'Visits',
|
||||
backgroundColor: MAIN_COLOR_ALPHA,
|
||||
borderColor: MAIN_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
if (highlightedData.every((value) => value === 0)) {
|
||||
return [ mainDataset ];
|
||||
}
|
||||
|
||||
const highlightedDataset: ChartDataset = {
|
||||
label: highlightedLabel ?? 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
||||
borderColor: HIGHLIGHTED_COLOR,
|
||||
borderWidth: 2,
|
||||
};
|
||||
|
||||
return [ mainDataset, highlightedDataset ];
|
||||
};
|
||||
const generateChartData = (
|
||||
labels: string[],
|
||||
data: number[],
|
||||
highlightedData: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartData => ({
|
||||
labels,
|
||||
datasets: generateChartDatasets(data, highlightedData, highlightedLabel),
|
||||
});
|
||||
|
||||
type ClickedCharts = [{ index: number }] | [];
|
||||
const chartElementAtEvent = (labels: string[], onClick?: (label: string) => void) => ([ chart ]: ClickedCharts) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick(labels[chart.index]);
|
||||
};
|
||||
|
||||
export const HorizontalBarChart: FC<HorizontalBarChartProps> = (
|
||||
{ stats, highlightedStats, highlightedLabel, onClick, max },
|
||||
) => {
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
const data = values(
|
||||
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||
if (acc[highlightedKey]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }),
|
||||
);
|
||||
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
|
||||
|
||||
const options: ChartOptions = {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
mode: 'y',
|
||||
// Do not show tooltip on items with empty label when in a bar chart
|
||||
filter: ({ label }) => label !== '',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
beginAtZero: true,
|
||||
stacked: true,
|
||||
max,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
y: { stacked: true },
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
indexAxis: 'y',
|
||||
};
|
||||
const chartData = generateChartData(labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(labels);
|
||||
|
||||
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
|
||||
const renderChartComponent = (customKey: string) => (
|
||||
<Bar
|
||||
key={`${height}_${customKey}`}
|
||||
data={chartData}
|
||||
options={options}
|
||||
height={height}
|
||||
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedStats !== undefined && renderChartComponent('with_stats')}
|
||||
{highlightedStats === undefined && renderChartComponent('without_stats')}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -21,14 +21,14 @@ import {
|
|||
startOfISOWeek,
|
||||
endOfISOWeek,
|
||||
} from 'date-fns';
|
||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
||||
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
|
||||
import { NormalizedVisit, Stats } from '../types';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import { useToggle } from '../../utils/helpers/hooks';
|
||||
import { rangeOf } from '../../utils/utils';
|
||||
import ToggleSwitch from '../../utils/ToggleSwitch';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
||||
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
|
||||
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
|
||||
import './LineChartCard.scss';
|
||||
|
||||
|
@ -134,11 +134,11 @@ const generateLabelsAndGroupedVisits = (
|
|||
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
|
||||
};
|
||||
|
||||
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
|
||||
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
|
||||
label,
|
||||
data,
|
||||
fill: false,
|
||||
lineTension: 0.2,
|
||||
tension: 0.2,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
});
|
||||
|
@ -146,15 +146,15 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
|
|||
let selectedLabel: string | null = null;
|
||||
|
||||
const chartElementAtEvent = (
|
||||
labels: string[],
|
||||
datasetsByPoint: Record<string, NormalizedVisit[]>,
|
||||
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
|
||||
) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
|
||||
) => ([ chart ]: [{ index: number }]) => {
|
||||
if (!setSelectedVisits || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _index: index, _chart: { data } } = chart;
|
||||
const { labels } = data as { labels: string[] };
|
||||
const { index } = chart;
|
||||
|
||||
if (selectedLabel === labels[index]) {
|
||||
setSelectedVisits([]);
|
||||
|
@ -183,42 +183,50 @@ const LineChartCard = (
|
|||
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
|
||||
[ highlightedVisits, step, labels ],
|
||||
);
|
||||
const generateChartDatasets = (): ChartDataset[] => {
|
||||
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
|
||||
|
||||
const data: ChartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
generateDataset(groupedVisits, 'Visits', MAIN_COLOR),
|
||||
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR),
|
||||
].filter(Boolean) as ChartDataSets[],
|
||||
if (highlightedVisits.length === 0) {
|
||||
return [ mainDataset ];
|
||||
}
|
||||
|
||||
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
|
||||
|
||||
return [ mainDataset, highlightedDataset ];
|
||||
};
|
||||
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
|
||||
|
||||
const options: ChartOptions = {
|
||||
maintainAspectRatio: false,
|
||||
legend: { display: false },
|
||||
scales: {
|
||||
yAxes: [
|
||||
{
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
],
|
||||
xAxes: [
|
||||
{
|
||||
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
|
||||
},
|
||||
],
|
||||
},
|
||||
tooltips: {
|
||||
intersect: false,
|
||||
axis: 'x',
|
||||
callbacks: {
|
||||
label: renderNonDoughnutChartLabel('yLabel'),
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
intersect: false,
|
||||
axis: 'x',
|
||||
callbacks: { label: renderChartLabel },
|
||||
},
|
||||
},
|
||||
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
title: { display: true, text: STEPS_MAP[step] },
|
||||
},
|
||||
},
|
||||
onHover: pointerOnHover,
|
||||
};
|
||||
const renderLineChart = () => (
|
||||
<Line
|
||||
data={generateChartData()}
|
||||
options={options}
|
||||
getElementAtEvent={chartElementAtEvent(labels, datasetsByPoint, setSelectedVisits) as any}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
@ -245,11 +253,10 @@ const LineChartCard = (
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="line-chart-card__body">
|
||||
<Line
|
||||
data={data}
|
||||
options={options}
|
||||
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
|
||||
/>
|
||||
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
|
||||
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
|
||||
{highlightedVisits.length > 0 && renderLineChart()}
|
||||
{highlightedVisits.length === 0 && renderLineChart()}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
|
@ -1,25 +1,26 @@
|
|||
import { useState } from 'react';
|
||||
import { FC, useState } from 'react';
|
||||
import { fromPairs, pipe, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
|
||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
||||
import SimplePaginator from '../../common/SimplePaginator';
|
||||
import { roundTen } from '../../utils/helpers/numbers';
|
||||
import SortingDropdown from '../../utils/SortingDropdown';
|
||||
import PaginationDropdown from '../../utils/PaginationDropdown';
|
||||
import { OrderDir, rangeOf } from '../../utils/utils';
|
||||
import { roundTen } from '../../utils/helpers/numbers';
|
||||
import SimplePaginator from '../../common/SimplePaginator';
|
||||
import { Stats, StatsRow } from '../types';
|
||||
import GraphCard from './GraphCard';
|
||||
import { DefaultChartProps } from './DefaultChart';
|
||||
import { HorizontalBarChart, HorizontalBarChartProps } from './HorizontalBarChart';
|
||||
import { ChartCard } from './ChartCard';
|
||||
|
||||
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
||||
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
||||
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
||||
|
||||
interface SortableBarGraphProps extends DefaultChartProps {
|
||||
interface SortableBarChartCardProps extends Omit<HorizontalBarChartProps, 'max'> {
|
||||
title: Function | string;
|
||||
sortingItems: Record<string, string>;
|
||||
withPagination?: boolean;
|
||||
extraHeaderContent?: Function;
|
||||
}
|
||||
|
||||
const SortableBarGraph = ({
|
||||
const toLowerIfString = (value: any) => type(value) === 'String' ? toLower(value) : value; // eslint-disable-line @typescript-eslint/no-unsafe-return
|
||||
const pickKeyFromPair = ([ key ]: StatsRow) => key;
|
||||
const pickValueFromPair = ([ , value ]: StatsRow) => value;
|
||||
|
||||
export const SortableBarChartCard: FC<SortableBarChartCardProps> = ({
|
||||
stats,
|
||||
highlightedStats,
|
||||
title,
|
||||
|
@ -27,7 +28,7 @@ const SortableBarGraph = ({
|
|||
extraHeaderContent,
|
||||
withPagination = true,
|
||||
...rest
|
||||
}: SortableBarGraphProps) => {
|
||||
}) => {
|
||||
const [ order, setOrder ] = useState<{ orderField?: string; orderDir?: OrderDir }>({
|
||||
orderField: undefined,
|
||||
orderDir: undefined,
|
||||
|
@ -131,16 +132,11 @@ const SortableBarGraph = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<GraphCard
|
||||
isBarChart
|
||||
<ChartCard
|
||||
title={computeTitle}
|
||||
stats={currentPageStats}
|
||||
highlightedStats={currentPageHighlightedStats}
|
||||
footer={pagination}
|
||||
max={max}
|
||||
{...rest}
|
||||
/>
|
||||
>
|
||||
<HorizontalBarChart stats={currentPageStats} highlightedStats={currentPageHighlightedStats} max={max} {...rest} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortableBarGraph;
|
|
@ -1,184 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
|
||||
import { keys, values } from 'ramda';
|
||||
import classNames from 'classnames';
|
||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
||||
import { fillTheGaps } from '../../utils/helpers/visits';
|
||||
import { Stats } from '../types';
|
||||
import { prettify } from '../../utils/helpers/numbers';
|
||||
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
|
||||
import {
|
||||
HIGHLIGHTED_COLOR,
|
||||
HIGHLIGHTED_COLOR_ALPHA,
|
||||
isDarkThemeEnabled,
|
||||
MAIN_COLOR,
|
||||
MAIN_COLOR_ALPHA,
|
||||
PRIMARY_DARK_COLOR,
|
||||
PRIMARY_LIGHT_COLOR,
|
||||
} from '../../utils/theme';
|
||||
import './DefaultChart.scss';
|
||||
|
||||
export interface DefaultChartProps {
|
||||
title: Function | string;
|
||||
stats: Stats;
|
||||
isBarChart?: boolean;
|
||||
max?: number;
|
||||
highlightedStats?: Stats;
|
||||
highlightedLabel?: string;
|
||||
onClick?: (label: string) => void;
|
||||
}
|
||||
|
||||
const generateGraphData = (
|
||||
title: Function | string,
|
||||
isBarChart: boolean,
|
||||
labels: string[],
|
||||
data: number[],
|
||||
highlightedData?: number[],
|
||||
highlightedLabel?: string,
|
||||
): ChartData => ({
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
title,
|
||||
label: highlightedData ? 'Non-selected' : 'Visits',
|
||||
data,
|
||||
backgroundColor: isBarChart ? MAIN_COLOR_ALPHA : [
|
||||
'#97BBCD',
|
||||
'#F7464A',
|
||||
'#46BFBD',
|
||||
'#FDB45C',
|
||||
'#949FB1',
|
||||
'#57A773',
|
||||
'#414066',
|
||||
'#08B2E3',
|
||||
'#B6C454',
|
||||
'#DCDCDC',
|
||||
'#463730',
|
||||
],
|
||||
borderColor: isBarChart ? MAIN_COLOR : isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
|
||||
borderWidth: 2,
|
||||
},
|
||||
highlightedData && {
|
||||
title,
|
||||
label: highlightedLabel ?? 'Selected',
|
||||
data: highlightedData,
|
||||
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
|
||||
borderColor: HIGHLIGHTED_COLOR,
|
||||
borderWidth: 2,
|
||||
},
|
||||
].filter(Boolean) as ChartDataSets[],
|
||||
});
|
||||
|
||||
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
|
||||
|
||||
const determineHeight = (isBarChart: boolean, labels: string[]): number | undefined => {
|
||||
if (!isBarChart) {
|
||||
return 300;
|
||||
}
|
||||
|
||||
return isBarChart && labels.length > 20 ? labels.length * 8 : undefined;
|
||||
};
|
||||
|
||||
const renderPieChartLegend = ({ config }: Chart) => {
|
||||
const { labels = [], datasets = [] } = config.data ?? {};
|
||||
const { defaultColor } = config.options ?? {} as any;
|
||||
const [{ backgroundColor: colors }] = datasets;
|
||||
|
||||
return (
|
||||
<ul className="default-chart__pie-chart-legend">
|
||||
{labels.map((label, index) => (
|
||||
<li key={label as string} className="default-chart__pie-chart-legend-item d-flex">
|
||||
<div
|
||||
className="default-chart__pie-chart-legend-item-color"
|
||||
style={{ backgroundColor: (colors as string[])[index] || defaultColor }}
|
||||
/>
|
||||
<small className="default-chart__pie-chart-legend-item-text flex-fill">{label}</small>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
const chartElementAtEvent = (onClick?: (label: string) => void) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
|
||||
if (!onClick || !chart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { _index, _chart: { data } } = chart;
|
||||
const { labels } = data;
|
||||
|
||||
onClick(labels?.[_index] as string);
|
||||
};
|
||||
|
||||
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
|
||||
|
||||
const DefaultChart = (
|
||||
{ title, isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps,
|
||||
) => {
|
||||
const Component = isBarChart ? HorizontalBar : Doughnut;
|
||||
const labels = keys(stats).map(dropLabelIfHidden);
|
||||
const data = values(
|
||||
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
|
||||
if (acc[highlightedKey]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }),
|
||||
);
|
||||
const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined;
|
||||
const [ chartRef, setChartRef ] = useState<HorizontalBar | Doughnut | undefined>();
|
||||
|
||||
const options: ChartOptions = {
|
||||
legend: { display: false },
|
||||
legendCallback: !isBarChart && renderPieChartLegend as any,
|
||||
scales: !isBarChart ? undefined : {
|
||||
xAxes: [
|
||||
{
|
||||
ticks: {
|
||||
beginAtZero: true,
|
||||
precision: 0,
|
||||
callback: prettify,
|
||||
max,
|
||||
},
|
||||
stacked: true,
|
||||
},
|
||||
],
|
||||
yAxes: [{ stacked: true }],
|
||||
},
|
||||
tooltips: {
|
||||
intersect: !isBarChart,
|
||||
// Do not show tooltip on items with empty label when in a bar chart
|
||||
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
||||
callbacks: {
|
||||
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
|
||||
},
|
||||
},
|
||||
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
|
||||
};
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
|
||||
const height = determineHeight(isBarChart, labels);
|
||||
|
||||
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
|
||||
return (
|
||||
<div className="row">
|
||||
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
|
||||
<Component
|
||||
ref={(element) => setChartRef(element ?? undefined)}
|
||||
key={height}
|
||||
data={graphData}
|
||||
options={options}
|
||||
height={height}
|
||||
getElementAtEvent={chartElementAtEvent(onClick)}
|
||||
/>
|
||||
</div>
|
||||
{!isBarChart && (
|
||||
<div className="col-sm-12 col-md-5">
|
||||
{chartRef?.chartInstance.generateLegend()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DefaultChart;
|
|
@ -1,20 +0,0 @@
|
|||
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
|
||||
import { ReactNode } from 'react';
|
||||
import DefaultChart, { DefaultChartProps } from './DefaultChart';
|
||||
import './GraphCard.scss';
|
||||
|
||||
interface GraphCardProps extends DefaultChartProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
const GraphCard = ({ title, footer, ...rest }: GraphCardProps) => (
|
||||
<Card>
|
||||
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
|
||||
<CardBody>
|
||||
<DefaultChart title={title} {...rest} />
|
||||
</CardBody>
|
||||
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export default GraphCard;
|
|
@ -1,8 +1,9 @@
|
|||
import { flatten, prop, range, splitEvery } from 'ramda';
|
||||
import { Action, Dispatch } from 'redux';
|
||||
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
|
||||
import { Visit, VisitsLoadFailedAction } from '../types';
|
||||
import { Visit } from '../types';
|
||||
import { parseApiError } from '../../api/utils';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
|
||||
const ITEMS_PER_PAGE = 5000;
|
||||
const PARALLEL_REQUESTS_COUNT = 4;
|
||||
|
@ -72,6 +73,6 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
|
|||
|
||||
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish });
|
||||
} catch (e) {
|
||||
dispatch<VisitsLoadFailedAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,17 +1,11 @@
|
|||
import { Action, Dispatch } from 'redux';
|
||||
import {
|
||||
OrphanVisit,
|
||||
OrphanVisitType,
|
||||
Visit,
|
||||
VisitsInfo,
|
||||
VisitsLoadFailedAction,
|
||||
VisitsLoadProgressChangedAction,
|
||||
} from '../types';
|
||||
import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkVisitsParams } from '../../api/types';
|
||||
import { isOrphanVisit } from '../types/helpers';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { getVisitsWithLoader } from './common';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||
|
||||
|
@ -31,7 +25,7 @@ export interface OrphanVisitsAction extends Action<string> {
|
|||
type OrphanVisitsCombinedAction = OrphanVisitsAction
|
||||
& VisitsLoadProgressChangedAction
|
||||
& CreateVisitsAction
|
||||
& VisitsLoadFailedAction;
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState: VisitsInfo = {
|
||||
visits: [],
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { Action, Dispatch } from 'redux';
|
||||
import { shortUrlMatches } from '../../short-urls/helpers';
|
||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
||||
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||
import { ShortUrlIdentifier } from '../../short-urls/data';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkVisitsParams } from '../../api/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { getVisitsWithLoader } from './common';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||
|
||||
|
@ -27,7 +28,7 @@ interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
|
|||
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
|
||||
& VisitsLoadProgressChangedAction
|
||||
& CreateVisitsAction
|
||||
& VisitsLoadFailedAction;
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState: ShortUrlVisits = {
|
||||
visits: [],
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { Action, Dispatch } from 'redux';
|
||||
import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types';
|
||||
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
|
||||
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
|
||||
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
|
||||
import { GetState } from '../../container/types';
|
||||
import { ShlinkVisitsParams } from '../../api/types';
|
||||
import { ApiErrorAction } from '../../api/types/actions';
|
||||
import { getVisitsWithLoader } from './common';
|
||||
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
|
||||
|
||||
|
@ -28,7 +29,7 @@ export interface TagVisitsAction extends Action<string> {
|
|||
type TagsVisitsCombinedAction = TagVisitsAction
|
||||
& VisitsLoadProgressChangedAction
|
||||
& CreateVisitsAction
|
||||
& VisitsLoadFailedAction;
|
||||
& ApiErrorAction;
|
||||
|
||||
const initialState: TagVisits = {
|
||||
visits: [],
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue