Compare commits

..

11 Commits

Author SHA1 Message Date
Alejandro Celaya
b747e63d51 Merge pull request #1524 from shlinkio/develop
Release 4.4.0
2025-04-20 17:07:11 +02:00
Alejandro Celaya
5aa113ec16 Merge pull request #1387 from shlinkio/develop
Release 4.3.0
2024-11-30 10:48:52 +01:00
Alejandro Celaya
2e438f9814 Merge pull request #1351 from shlinkio/develop
Release 4.2.2
2024-10-19 12:15:18 +02:00
Alejandro Celaya
9a798c20c0 Merge pull request #1329 from shlinkio/develop
Release 4.2.1
2024-10-09 14:33:56 +02:00
Alejandro Celaya
9e1a803b8d Merge pull request #1324 from shlinkio/develop
Release 4.2.0
2024-10-07 09:54:43 +02:00
Alejandro Celaya
d18ebf8911 Merge pull request #1147 from shlinkio/develop
Release 4.1.2
2024-04-17 09:59:22 +02:00
Alejandro Celaya
5c2e99cba1 Merge pull request #1137 from shlinkio/develop
Release 4.1.1
2024-04-11 09:24:15 +02:00
Alejandro Celaya
c75a3a4073 Merge pull request #1103 from shlinkio/develop
Release 4.1.0
2024-03-17 12:26:34 +01:00
Alejandro Celaya
e68643108a Merge pull request #1049 from shlinkio/develop
Release 4.0.1
2024-02-01 08:58:14 +01:00
Alejandro Celaya
8a7a51be2f Merge pull request #1045 from shlinkio/develop
Release 4.0.0
2024-01-29 19:11:22 +01:00
Alejandro Celaya
f5e92c6897 Merge pull request #848 from shlinkio/develop
Release 3.10.2
2023-07-09 10:17:23 +02:00
126 changed files with 8024 additions and 5581 deletions

24
.github/DISCUSSION_TEMPLATE/q-a.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
title: 'Q&A'
body:
- type: input
validations:
required: true
attributes:
label: shlink-web-client version
placeholder: x.y.z
- type: dropdown
validations:
required: true
attributes:
label: How do you use shlink-web-client
options:
- https://app.shlink.io
- Docker image
- Self-hosted
- Other (explain in summary)
- type: textarea
validations:
required: true
attributes:
label: Summary
value: '<!-- Describe your issue, question or request here. -->'

7
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,7 @@
<!--
Before opening an issue, just take into account that this is a completely free of charge and open source project.
I'm always happy to help and provide support, but some understanding will be expected.
I do this in my own free time, so expect some delays when implementing new features and fixing bugs, and don't take it personal if an issue gets eventually closed.
You may also be asked to provide tests or ways to reproduce reported bugs.
Try to be polite, and understand it is impossible for an OSS project to cover all use cases.
-->

View File

@@ -2,4 +2,4 @@ blank_issues_enabled: true
contact_links:
- name: Question - Support
about: Do you need help setting up or using shlink-web-client?
url: https://github.com/orgs/shlinkio/discussions/new?category=help-wanted
url: https://github.com/shlinkio/shlink-web-client/discussions/new?category=q-a

View File

@@ -45,6 +45,10 @@ updates:
patterns:
- 'tailwindcss'
- '@tailwindcss/*'
ignore:
# Bootstrap can introduce visual breaking changes on styles
# Ignore it, since the plan is to remove it anyway
- dependency-name: 'bootstrap'
- package-ecosystem: docker
directory: '/'
schedule:

View File

@@ -4,139 +4,6 @@ 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).
## [4.7.0] - 2026-02-04
### Added
* [shlink-web-component] Add support for Shlink 5.0.0, by supporting date-based redirect conditions.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.6.2] - 2025-11-15
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#878](https://github.com/shlinkio/shlink-web-component/issues/878) Fix real-time updates interval setting being ignored.
## [4.6.1] - 2025-11-15
### Added
* *Nothing*
### Changed
* [#802](https://github.com/shlinkio/shlink-web-client/issues/802) Improve dependency injection in components.
* Stop injecting redux state and actions.
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* Fix small UI issues.
## [4.6.0] - 2025-11-12
### Added
* [shlink-web-component#839](https://github.com/shlinkio/shlink-web-component/issues/839) Allow filtering short URLs by excluded tags when using Shlink >=4.6.0
* [shlink-web-component#838](https://github.com/shlinkio/shlink-web-component/issues/838) Allow filtering tag, orphan and non-orphan visits by domain, when using Shlink >=4.6.0
* [shlink-web-component#784](https://github.com/shlinkio/shlink-web-component/issues/784) Add optional `long-url` query parameter to short URL creation to prefill the long URL programmatically.
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* Drop support for Shlink older than 4.0.0
### Fixed
* *Nothing*
## [4.5.1] - 2025-08-13
### Added
* *Nothing*
### Changed
* *Nothing*
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [#1637](https://github.com/shlinkio/shlink-web-client/issues/1637) Fix brand color used in PWA
* [#1636](https://github.com/shlinkio/shlink-web-client/issues/1636) Make sure sidebar toggle is rendered only in sections where the sidebar exists.
## [4.5.0] - 2025-08-08
### Added
* [shlink-web-component#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
* [shlink-web-component#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
* [shlink-web-component#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.
* [shlink-web-component#657](https://github.com/shlinkio/shlink-web-component/issues/657) Allow visits table columns to be customized via settings, and add a new optional "Region" column.
As a side effect, the "Show user agent" toggle has been removed from the list, as this can now be globally configured in the settings.
### Changed
* Update to FontAwesome 7
* Update to Recharts 3
* Update to `@shlinkio/shlink-web-component` 0.16.1
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* [shlink-web-component#698](https://github.com/shlinkio/shlink-web-component/issues/698) Fix line chart selection triggering after clicking a dot in the chart. It now works only when dragging while the mouse is clicked.
## [4.4.1] - 2025-06-23
### Added
* *Nothing*
### Changed
* [shlink-web-component#661](https://github.com/shlinkio/shlink-web-component/issues/661) and [#1571](https://github.com/shlinkio/shlink-web-client/issues/1571) Fully replace bootstrap with tailwind.
* Add the new light theme brand color.
* Update to `@shlinkio/shlink-frontend-kit` 1.0.0 and `@shlinkio/shlink-web-component` 0.15
* Replace reactstrap nav bar with `NavBar` component from `@shlinkio/shlink-frontend-kit`
### Deprecated
* *Nothing*
### Removed
* *Nothing*
### Fixed
* *Nothing*
## [4.4.0] - 2025-04-20
### Added
* [#1510](https://github.com/shlinkio/shlink-web-client/issues/1510) Existing HTTP credentials (cookies, TLS certs, authentication headers) can now be forwarded to the API server if appropriate [CORS headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Credentials) are set

View File

@@ -1,10 +1,10 @@
FROM node:25.6-alpine AS node
FROM node:23.11-alpine AS node
COPY . /shlink-web-client
ARG VERSION="latest"
ENV VERSION=${VERSION}
RUN cd /shlink-web-client && npm ci && node --run build
FROM nginxinc/nginx-unprivileged:1.29-alpine
FROM nginxinc/nginx-unprivileged:1.27-alpine
ARG UID=101
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"

View File

@@ -1,4 +1,4 @@
FROM mcr.microsoft.com/playwright:v1.58.2-noble
FROM mcr.microsoft.com/playwright:v1.52.0-noble
ENV NODE_VERSION 22.14
ENV TINI_VERSION v0.19.0

View File

@@ -5,8 +5,7 @@ services:
build:
context: .
dockerfile: ./dev.Dockerfile
working_dir: /home/shlink/www
command: /bin/sh -c "npm install && npm run start"
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
volumes:
- ./:/home/shlink/www
ports:

View File

@@ -3,8 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#2078CF" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0B2D4E" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#4696e5">
<!--
manifest.json provides metadata used when your web app is added to the
@@ -85,7 +84,7 @@
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root" class="h-full"></div>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -1,13 +1,12 @@
import { BRAND_COLOR_LM } from '@shlinkio/shlink-frontend-kit';
import type { ManifestOptions } from 'vite-plugin-pwa';
export const manifest: Partial<ManifestOptions> = {
short_name: 'Shlink',
name: 'Shlink Web Client',
name: 'Shlink',
start_url: '/',
display: 'standalone',
theme_color: BRAND_COLOR_LM, // Toolbar color
background_color: BRAND_COLOR_LM, // Splash screen background color
theme_color: '#4696e5',
background_color: '#4696e5',
icons: [
{
src: './icons/icon-16x16.png',

11016
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
"license": "MIT",
"type": "module",
"scripts": {
"lint": "eslint src test config/test *.config.{js,ts}",
"lint": "eslint src test config/test",
"lint:fix": "node --run lint -- --fix",
"types": "tsc",
"start": "vite serve --host=0.0.0.0",
@@ -20,68 +20,69 @@
"test:verbose": "node --run test -- --verbose"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.2.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@json2csv/plainjs": "^7.0.6",
"@reduxjs/toolkit": "^2.11.2",
"@shlinkio/data-manipulation": "^1.0.4",
"@shlinkio/shlink-frontend-kit": "^1.4.0",
"@shlinkio/shlink-js-sdk": "^3.1.0",
"@shlinkio/shlink-web-component": "^0.18.0",
"@vitest/browser-playwright": "^4.0.18",
"@reduxjs/toolkit": "^2.7.0",
"@shlinkio/data-manipulation": "^1.0.3",
"@shlinkio/shlink-frontend-kit": "^0.8.12",
"@shlinkio/shlink-js-sdk": "^2.1.0",
"@shlinkio/shlink-web-component": "^0.13.3",
"bootstrap": "5.2.3",
"bottlejs": "^2.0.1",
"clsx": "^2.1.1",
"compare-versions": "^6.1.1",
"csvtojson": "^2.0.14",
"csvtojson": "^2.0.10",
"date-fns": "^4.1.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-external-link": "^2.6.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-external-link": "^2.5.0",
"react-redux": "^9.2.0",
"react-router": "^7.13.0",
"react-router": "^7.5.1",
"reactstrap": "^9.2.3",
"redux-localstorage-simple": "^2.5.1",
"workbox-core": "^7.4.0",
"workbox-expiration": "^7.4.0",
"workbox-precaching": "^7.4.0",
"workbox-routing": "^7.4.0",
"workbox-strategies": "^7.4.0"
"workbox-core": "^7.3.0",
"workbox-expiration": "^7.3.0",
"workbox-precaching": "^7.3.0",
"workbox-routing": "^7.3.0",
"workbox-strategies": "^7.3.0"
},
"devDependencies": {
"@shlinkio/eslint-config-js-coding-standard": "~3.7.0",
"@stylistic/eslint-plugin": "^5.7.1",
"@tailwindcss/vite": "^4.2.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
"@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/vite": "^4.1.4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@total-typescript/shoehorn": "^0.1.2",
"@types/node": "^25.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/browser": "^4.0.3",
"@vitest/coverage-v8": "^4.0.18",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.0",
"@vitest/browser": "^3.1.1",
"@vitest/coverage-v8": "^3.1.1",
"adm-zip": "^0.5.16",
"axe-core": "^4.11.1",
"chalk": "^5.6.2",
"eslint": "^9.39.2",
"eslint-plugin-import": "^2.32.0",
"axe-core": "^4.10.3",
"chalk": "^5.4.1",
"eslint": "^9.25.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.0.0-beta-714736e-20250131",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-compiler": "^19.0.0-beta-ebf51a3-20250411",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"history": "^5.3.0",
"playwright": "^1.58.2",
"playwright": "^1.52.0",
"sass": "^1.86.3",
"tailwindcss": "^4.1.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.3"
"typescript": "^5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.2",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.0.5"
},
"browserslist": [
">0.2%",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 B

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,8 +1 @@
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill="#2078CF">
<path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z"/>
<path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z"/>
<path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z"/>
<path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z"/>
</g>
</svg>
<svg width="512pt" height="512pt" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg"><g fill="#4595e3"><path d=" M 23.71 85.08 C 17.22 49.81 49.44 14.86 85.08 18.12 C 118.83 19.21 145.72 53.33 139.45 86.37 C 155.64 102.30 171.32 118.83 187.87 134.36 C 198.32 111.73 208.84 89.12 219.57 66.62 C 226.05 53.84 243.47 48.74 255.73 56.27 C 263.76 62.10 270.34 69.69 277.25 76.75 C 286.28 86.61 285.72 102.89 276.31 112.31 C 223.38 165.37 170.38 218.37 117.35 271.34 C 107.72 280.99 91.01 281.25 81.11 271.86 C 74.39 264.94 66.82 258.69 61.24 250.77 C 53.72 238.52 58.85 221.07 71.64 214.62 C 94.11 203.87 116.72 193.38 139.33 182.91 C 123.81 166.36 107.30 150.68 91.37 134.49 C 60.20 140.28 27.37 116.78 23.71 85.08 Z" /><path d=" M 205.21 201.23 C 225.32 181.36 260.88 181.11 281.14 200.86 C 299.25 218.75 317.37 236.65 335.10 254.93 C 356.73 278.01 352.01 318.70 326.03 336.56 C 320.07 330.47 313.73 324.65 308.12 318.28 C 323.86 309.39 328.76 286.18 316.63 272.39 C 301.73 256.95 286.30 242.03 271.24 226.75 C 264.49 219.65 256.80 212.00 246.37 211.52 C 224.65 208.64 205.52 233.36 214.49 253.58 C 221.09 266.81 234.22 275.12 243.62 286.24 C 240.43 295.96 238.09 306.13 238.29 316.46 C 225.55 304.29 213.16 291.73 200.89 279.09 C 180.97 257.57 183.10 220.45 205.21 201.23 Z" /><path d=" M 273.90 352.07 C 252.28 328.99 256.98 288.31 282.96 270.46 C 288.93 276.54 295.26 282.36 300.88 288.72 C 285.14 297.62 280.23 320.82 292.38 334.61 C 307.27 350.05 322.70 364.96 337.75 380.25 C 344.51 387.35 352.20 395.00 362.64 395.48 C 384.35 398.37 403.49 373.64 394.51 353.42 C 387.92 340.18 374.78 331.88 365.38 320.76 C 368.56 311.04 370.91 300.86 370.71 290.54 C 383.45 302.70 395.84 315.27 408.11 327.91 C 428.03 349.43 425.90 386.55 403.78 405.77 C 383.68 425.64 348.13 425.89 327.86 406.14 C 309.75 388.25 291.60 370.37 273.90 352.07 Z" /><path d=" M 422.11 403.83 C 431.96 394.07 441.60 384.06 451.66 374.51 C 460.90 383.74 471.89 392.70 474.89 406.11 C 480.16 429.97 484.08 454.13 488.76 478.12 C 490.00 483.41 484.47 488.29 479.35 486.63 C 454.66 481.52 429.55 478.12 405.14 471.84 C 393.17 467.97 385.20 457.75 376.55 449.27 C 386.39 439.49 396.13 429.60 406.06 419.91 C 416.37 433.45 435.74 414.00 422.11 403.83 Z" /></g></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 B

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 381 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 B

After

Width:  |  Height:  |  Size: 437 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 466 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 642 B

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 B

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 908 B

After

Width:  |  Height:  |  Size: 750 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 984 B

View File

@@ -1,20 +1,23 @@
// Do this as the first thing so that any code reading it knows the right env.
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
import chalk from 'chalk';
import AdmZip from 'adm-zip';
import fs from 'fs';
function zipDist(version) {
const fileBaseName = `shlink-web-client_${version}_dist`;
const versionFileName = `./dist/${fileBaseName}.zip`;
const versionFileName = `./dist/shlink-web-client_${version}_dist.zip`;
console.log(chalk.cyan(`Generating dist file for version ${chalk.bold(version)}...`));
const zip = new AdmZip();
try {
if (fs.existsSync(versionFileName)) {
fs.unlinkSync(versionFileName);
fs.unlink(versionFileName);
}
zip.addLocalFolder('./build', fileBaseName);
zip.addLocalFolder('./build', `shlink-web-client_${version}_dist`);
zip.writeZip(versionFileName);
console.log(chalk.green('Dist file properly generated'));
} catch (e) {

View File

@@ -1,11 +1,13 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
import type { GetState } from '../../container/types';
import type { ServerWithId } from '../../servers/data';
import { hasServerData } from '../../servers/data';
import type { GetState } from '../../store';
const apiClients: Map<string, ShlinkApiClient> = new Map();
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
typeof getStateOrSelectedServer === 'function';
const getSelectedServerFromState = (getState: GetState): ServerWithId => {
const { selectedServer } = getState();
if (!hasServerData(selectedServer)) {
@@ -16,7 +18,7 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
};
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
const { url: baseUrl, apiKey, forwardCredentials } = typeof getStateOrSelectedServer === 'function'
const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer)
? getSelectedServerFromState(getStateOrSelectedServer)
: getStateOrSelectedServer;
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
@@ -32,7 +34,6 @@ export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelec
{ requestCredentials: forwardCredentials ? 'include' : undefined },
);
apiClients.set(serverKey, apiClient);
return apiClient;
};

View File

@@ -0,0 +1,6 @@
import type Bottle from 'bottlejs';
import { buildShlinkApiClient } from './ShlinkApiClientBuilder';
export const provideServices = (bottle: Bottle) => {
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
};

View File

@@ -1,71 +1,109 @@
import { changeThemeInMarkup, getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { Route, Routes, useLocation } from 'react-router';
import { AppUpdateBanner } from '../common/AppUpdateBanner';
import { Home } from '../common/Home';
import { MainHeader } from '../common/MainHeader';
import { NotFound } from '../common/NotFound';
import { ShlinkVersionsContainer } from '../common/ShlinkVersionsContainer';
import { ShlinkWebComponentContainer } from '../common/ShlinkWebComponentContainer';
import { CreateServer } from '../servers/CreateServer';
import { EditServer } from '../servers/EditServer';
import { ManageServers } from '../servers/ManageServers';
import { useLoadRemoteServers } from '../servers/reducers/remoteServers';
import { useSettings } from '../settings/reducers/settings';
import { Settings } from '../settings/Settings';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from '../servers/data';
import { forceUpdate } from '../utils/helpers/sw';
import { useAppUpdated } from './reducers/appUpdates';
export const App: FC = () => {
const { appUpdated, resetAppUpdate } = useAppUpdated();
type AppProps = {
fetchServers: () => void;
servers: ServersMap;
settings: Settings;
resetAppUpdate: () => void;
appUpdated: boolean;
};
useLoadRemoteServers();
type AppDeps = {
MainHeader: FC;
Home: FC;
ShlinkWebComponentContainer: FC;
CreateServer: FC;
EditServer: FC;
Settings: FC;
ManageServers: FC;
ShlinkVersionsContainer: FC;
};
const App: FCWithDeps<AppProps, AppDeps> = (
{ fetchServers, servers, settings, appUpdated, resetAppUpdate },
) => {
const {
MainHeader,
Home,
ShlinkWebComponentContainer,
CreateServer,
EditServer,
Settings,
ManageServers,
ShlinkVersionsContainer,
} = useDependencies(App);
const location = useLocation();
const initialServers = useRef(servers);
const isHome = location.pathname === '/';
const { settings } = useSettings();
useEffect(() => {
// Try to fetch the remote servers if the list is empty during first render.
// We use a ref because we don't care if the servers list becomes empty later.
if (Object.keys(initialServers.current).length === 0) {
fetchServers();
}
}, [fetchServers]);
useEffect(() => {
changeThemeInMarkup(settings.ui?.theme ?? getSystemPreferredTheme());
}, [settings.ui?.theme]);
return (
<div className="h-full">
<>
<MainHeader />
<div className="tw:px-3 tw:h-full">
<MainHeader />
<div className="h-full pt-(--header-height)">
<div
data-testid="shlink-wrapper"
className={clsx(
'min-h-full pb-[calc(var(--footer-height)+var(--footer-margin))] -mb-[calc(var(--footer-height)+var(--footer-margin))]',
{ 'flex items-center pt-4': isHome },
)}
>
<Routes>
<Route index element={<Home />} />
<Route path="/settings">
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
</Route>
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
<div className="h-(--footer-height) mt-(--footer-margin) md:px-4">
<ShlinkVersionsContainer />
</div>
<div className="tw:h-full tw:pt-(--header-height)">
<div
data-testid="shlink-wrapper"
className={clsx(
'tw:min-h-full tw:pb-[calc(var(--footer-height)+var(--footer-margin))] tw:-mb-[calc(var(--footer-height)+var(--footer-margin))]',
{ 'tw:flex tw:items-center tw:pt-4': isHome },
)}
>
<Routes>
<Route index element={<Home />} />
<Route path="/settings">
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
</Route>
<Route path="/manage-servers" element={<ManageServers />} />
<Route path="/server/create" element={<CreateServer />} />
<Route path="/server/:serverId/edit" element={<EditServer />} />
<Route path="/server/:serverId">
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</div>
</>
<div className="tw:h-(--footer-height) tw:mt-(--footer-margin) tw:md:px-4">
<ShlinkVersionsContainer />
</div>
</div>
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
</div>
);
};
export const AppFactory = componentFactory(App, [
'MainHeader',
'Home',
'ShlinkWebComponentContainer',
'CreateServer',
'EditServer',
'Settings',
'ManageServers',
'ShlinkVersionsContainer',
]);

View File

@@ -1,6 +1,4 @@
import { createSlice } from '@reduxjs/toolkit';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
const { actions, reducer } = createSlice({
name: 'shlink/appUpdates',
@@ -14,12 +12,3 @@ const { actions, reducer } = createSlice({
export const { appUpdateAvailable, resetAppUpdate } = actions;
export const appUpdatesReducer = reducer;
export const useAppUpdated = () => {
const dispatch = useAppDispatch();
const appUpdateAvailable = useCallback(() => dispatch(actions.appUpdateAvailable()), [dispatch]);
const resetAppUpdate = useCallback(() => dispatch(actions.resetAppUpdate()), [dispatch]);
const appUpdated = useAppSelector((state) => state.appUpdated);
return { appUpdated, appUpdateAvailable, resetAppUpdate };
};

View File

@@ -0,0 +1,14 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { AppFactory } from '../App';
import { appUpdateAvailable, resetAppUpdate } from '../reducers/appUpdates';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.factory('App', AppFactory);
bottle.decorator('App', connect(['servers', 'settings', 'appUpdated'], ['fetchServers', 'resetAppUpdate']));
// Actions
bottle.serviceFactory('appUpdateAvailable', () => appUpdateAvailable);
bottle.serviceFactory('resetAppUpdate', () => resetAppUpdate);
};

View File

@@ -1,6 +1,7 @@
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card, CloseButton,useToggle } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Card, CloseButton } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback } from 'react';
@@ -12,7 +13,7 @@ interface AppUpdateBannerProps {
}
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
const { flag: isUpdating, setToTrue: setUpdating } = useToggle();
const [isUpdating,, setUpdating] = useToggle();
const update = useCallback(() => {
setUpdating();
forceUpdate();
@@ -26,15 +27,15 @@ export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, for
<Card
role="alert"
className={clsx(
'w-[700px] max-w-[calc(100%-30px)]',
'fixed top-[35px] left-[50%] translate-x-[-50%] z-[1040]',
'tw:w-[700px] tw:max-w-[calc(100%-30px)]',
'tw:fixed tw:top-[35px] tw:left-[50%] tw:translate-x-[-50%] tw:z-[1040]',
)}
>
<Card.Header className="flex items-center justify-between">
<Card.Header className="tw:flex tw:items-center tw:justify-between">
<h5>This app has just been updated!</h5>
<CloseButton onClick={onClose} />
</Card.Header>
<Card.Body className="flex gap-4 items-center justify-between max-md:flex-col">
<Card.Body className="tw:flex tw:gap-4 tw:items-center tw:justify-between tw:max-md:flex-col">
Restart it to enjoy the new features.
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}

View File

@@ -1,4 +1,4 @@
import { Button } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { PropsWithChildren, ReactNode } from 'react';
import { Component } from 'react';
import { ErrorLayout } from './ErrorLayout';

View File

@@ -1,4 +1,4 @@
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
import { SimpleCard } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react';
export type ErrorLayoutProps = PropsWithChildren<{
@@ -6,8 +6,8 @@ export type ErrorLayoutProps = PropsWithChildren<{
}>;
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
<div className="pt-4">
<SimpleCard className="p-4 w-full lg:w-[65%] m-auto">
<div className="tw:pt-4">
<SimpleCard className="tw:p-4 tw:w-full tw:lg:w-[65%] tw:m-auto">
<h2>{title}</h2>
{children}
</SimpleCard>

View File

@@ -1,19 +1,20 @@
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Card } from '@shlinkio/shlink-frontend-kit';
import { Button, Card } from '@shlinkio/shlink-frontend-kit/tailwind';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react';
import { ExternalLink } from 'react-external-link';
import { useNavigate } from 'react-router';
import { withoutSelectedServer } from '../servers/helpers/withoutSelectedServer';
import { useServers } from '../servers/reducers/servers';
import type { ServersMap } from '../servers/data';
import { ServersListGroup } from '../servers/ServersListGroup';
import { ShlinkLogo } from './img/ShlinkLogo';
export const Home: FC = withoutSelectedServer(() => {
export type HomeProps = {
servers: ServersMap;
};
export const Home = ({ servers }: HomeProps) => {
const navigate = useNavigate();
const { servers } = useServers();
const serversList = Object.values(servers);
const hasServers = serversList.length > 0;
@@ -26,36 +27,36 @@ export const Home: FC = withoutSelectedServer(() => {
}, [serversList, navigate]);
return (
<div className="px-3 w-full">
<Card className="mx-auto max-w-[720px] overflow-hidden">
<div className="flex flex-col md:flex-row">
<div className="p-6 hidden md:flex items-center w-[40%]">
<div className="w-full">
<div className="tw:w-full">
<Card className="tw:mx-auto tw:max-w-[720px] tw:overflow-hidden">
<div className="tw:flex tw:flex-col tw:md:flex-row">
<div className="tw:p-6 tw:hidden tw:md:flex tw:items-center tw:w-[40%]">
<div className="tw:w-full">
<ShlinkLogo />
</div>
</div>
<div className="md:border-l border-lm-border dark:border-dm-border flex-grow">
<div className="tw:md:border-l tw:border-lm-border tw:dark:border-dm-border tw:flex-grow">
<h1
className={clsx(
'p-4 text-center border-lm-border dark:border-dm-border',
{ 'border-b': !hasServers },
'tw:p-4 tw:text-center tw:border-lm-border tw:dark:border-dm-border',
{ 'tw:border-b': !hasServers },
)}
>
Welcome!
</h1>
{hasServers ? <ServersListGroup servers={serversList} /> : (
<div className="p-6 text-center flex flex-col gap-12 text-xl">
<div className="tw:p-6 tw:text-center tw:flex tw:flex-col tw:gap-12 tw:text-xl">
<p>This application will help you manage your Shlink servers.</p>
<p>
<Button to="/server/create" size="lg" inline>
<FontAwesomeIcon icon={faPlus} widthAuto /> Add a server
<FontAwesomeIcon icon={faPlus} /> Add a server
</Button>
</p>
<p>
<ExternalLink href="https://shlink.io/documentation">
<small>
<span className="mr-2">Learn more about Shlink</span>
<span className="tw:mr-2">Learn more about Shlink</span>
<FontAwesomeIcon icon={faExternalLinkAlt} />
</small>
</ExternalLink>
@@ -67,4 +68,4 @@ export const Home: FC = withoutSelectedServer(() => {
</Card>
</div>
);
});
};

View File

@@ -1,33 +1,55 @@
import { faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { faChevronDown as arrowIcon, faCogs as cogsIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { NavBar } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC } from 'react';
import { useEffect } from 'react';
import { Link, useLocation } from 'react-router';
import { ServersDropdown } from '../servers/ServersDropdown';
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { ShlinkLogo } from './img/ShlinkLogo';
export const MainHeader: FC = () => {
const { pathname } = useLocation();
type MainHeaderDeps = {
ServersDropdown: FC;
};
const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
const { ServersDropdown } = useDependencies(MainHeader);
const [isNotCollapsed, toggleCollapse, , collapse] = useToggle();
const location = useLocation();
const { pathname } = location;
// In mobile devices, collapse the navbar when location changes
useEffect(collapse, [location, collapse]);
const settingsPath = '/settings';
return (
<NavBar
className="[&]:fixed top-0 z-900"
brand={(
<Link to="/" className="[&]:text-white no-underline flex items-center gap-2">
<ShlinkLogo className="w-7" color="white" /> <small className="font-normal">Shlink</small>
</Link>
)}
>
<NavBar.MenuItem
to={settingsPath}
active={pathname.startsWith(settingsPath)}
className="flex items-center gap-1.5"
>
<FontAwesomeIcon icon={cogsIcon} /> Settings
</NavBar.MenuItem>
<ServersDropdown />
</NavBar>
<Navbar color="primary" dark fixed="top" expand="md" className="tw:text-white tw:bg-lm-brand tw:dark:bg-dm-brand">
<NavbarBrand tag={Link} to="/">
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
</NavbarBrand>
<NavbarToggler onClick={toggleCollapse}>
<FontAwesomeIcon
icon={arrowIcon}
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
/>
</NavbarToggler>
<Collapse navbar isOpen={isNotCollapsed}>
<Nav navbar className="tw:ml-auto">
<NavItem>
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
<FontAwesomeIcon icon={cogsIcon} />&nbsp; Settings
</NavLink>
</NavItem>
<ServersDropdown />
</Nav>
</Collapse>
</Navbar>
);
};
export const MainHeaderFactory = componentFactory(MainHeader, ['ServersDropdown']);

View File

@@ -6,7 +6,7 @@ export type NoMenuLayoutProps = PropsWithChildren & {
};
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
<div className={clsx('container mx-auto p-5 pt-8 max-md:p-3 max-md:py-4', className)}>
<div className={clsx('tw:container tw:mx-auto tw:p-5 tw:pt-8 tw:max-md:p-0 tw:max-md:py-4', className)}>
{children}
</div>
);

View File

@@ -1,4 +1,4 @@
import { Button } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren } from 'react';
import { ErrorLayout } from './ErrorLayout';

View File

@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
}
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-gray-500">
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="tw:text-gray-500">
<b>{version}</b>
</ExternalLink>
);
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
const normalizedClientVersion = normalizeVersion(clientVersion);
return (
<small className="text-gray-500">
<small className="tw:text-gray-500">
{isReachableServer(selectedServer) && (
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
)}

View File

@@ -1,15 +1,16 @@
import { clsx } from 'clsx';
import type { SelectedServer } from '../servers/data';
import { isReachableServer } from '../servers/data';
import { useSelectedServer } from '../servers/reducers/selectedServer';
import { ShlinkVersions } from './ShlinkVersions';
export const ShlinkVersionsContainer = () => {
const { selectedServer } = useSelectedServer();
return (
<div
className={clsx('text-center', { 'md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);
export type ShlinkVersionsContainerProps = {
selectedServer: SelectedServer;
};
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
<div
className={clsx('tw:text-center', { 'tw:md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
>
<ShlinkVersions selectedServer={selectedServer} />
</div>
);

View File

@@ -1,37 +1,40 @@
import type { TagColorsStorage } from '@shlinkio/shlink-web-component';
import {
ShlinkSidebarToggleButton,
ShlinkSidebarVisibilityProvider,
ShlinkWebComponent,
} from '@shlinkio/shlink-web-component';
import type { ShlinkWebComponentType, TagColorsStorage } from '@shlinkio/shlink-web-component';
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { memo } from 'react';
import type { ShlinkApiClientBuilder } from '../api/services/ShlinkApiClientBuilder';
import { withDependencies } from '../container/context';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { isReachableServer } from '../servers/data';
import { ServerError } from '../servers/helpers/ServerError';
import type { WithSelectedServerProps } from '../servers/helpers/withSelectedServer';
import { withSelectedServer } from '../servers/helpers/withSelectedServer';
import { useSelectedServer } from '../servers/reducers/selectedServer';
import { useSettings } from '../settings/reducers/settings';
import { NotFound } from './NotFound';
export type ShlinkWebComponentContainerProps = {
TagColorsStorage: TagColorsStorage;
buildShlinkApiClient: ShlinkApiClientBuilder;
type ShlinkWebComponentContainerProps = WithSelectedServerProps & {
settings: Settings;
};
const ShlinkWebComponentContainerBase: FC<
ShlinkWebComponentContainerProps
type ShlinkWebComponentContainerDeps = {
buildShlinkApiClient: ShlinkApiClientBuilder,
TagColorsStorage: TagColorsStorage,
ShlinkWebComponent: ShlinkWebComponentType,
ServerError: FC,
};
const ShlinkWebComponentContainer: FCWithDeps<
ShlinkWebComponentContainerProps,
ShlinkWebComponentContainerDeps
// FIXME Using `memo` here to solve a flickering effect in charts.
// memo is probably not the right solution. The root cause is the withSelectedServer HOC, but I couldn't fix the
// extra rendering there.
// This should be revisited at some point.
> = withSelectedServer(memo(({
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
}) => {
const { selectedServer } = useSelectedServer();
const { settings } = useSettings();
> = withSelectedServer(memo(({ selectedServer, settings }) => {
const {
buildShlinkApiClient,
TagColorsStorage: tagColorsStorage,
ShlinkWebComponent,
ServerError,
} = useDependencies(ShlinkWebComponentContainer);
if (!isReachableServer(selectedServer)) {
return <ServerError />;
@@ -39,24 +42,22 @@ const ShlinkWebComponentContainerBase: FC<
const routesPrefix = `/server/${selectedServer.id}`;
return (
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton className="fixed top-3.5 left-3 z-901" />
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath: string) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
<ShlinkWebComponent
serverVersion={selectedServer.version}
apiClient={buildShlinkApiClient(selectedServer)}
settings={settings}
routesPrefix={routesPrefix}
tagColorsStorage={tagColorsStorage}
createNotFound={(nonPrefixedHomePath) => (
<NotFound to={`${routesPrefix}${nonPrefixedHomePath}`}>List short URLs</NotFound>
)}
/>
);
}));
export const ShlinkWebComponentContainer = withDependencies(ShlinkWebComponentContainerBase, [
export const ShlinkWebComponentContainerFactory = componentFactory(ShlinkWebComponentContainer, [
'buildShlinkApiClient',
'TagColorsStorage',
'ShlinkWebComponent',
'ServerError',
]);

View File

@@ -1,11 +1,11 @@
import { brandColor } from '@shlinkio/shlink-frontend-kit';
import { MAIN_COLOR } from '@shlinkio/shlink-frontend-kit';
export interface ShlinkLogoProps {
color?: string;
className?: string;
}
export const ShlinkLogo = ({ color = brandColor(), className }: ShlinkLogoProps) => (
export const ShlinkLogo = ({ color = MAIN_COLOR, className }: ShlinkLogoProps) => (
<svg className={className} viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g fill={color}>
<path

View File

@@ -0,0 +1,37 @@
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { ErrorHandler } from '../ErrorHandler';
import { Home } from '../Home';
import { MainHeaderFactory } from '../MainHeader';
import { ScrollToTop } from '../ScrollToTop';
import { ShlinkVersionsContainer } from '../ShlinkVersionsContainer';
import { ShlinkWebComponentContainerFactory } from '../ShlinkWebComponentContainer';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Services
bottle.constant('window', window);
bottle.constant('console', console);
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', FetchHttpClient, 'fetch');
// Components
bottle.serviceFactory('ScrollToTop', () => ScrollToTop);
bottle.factory('MainHeader', MainHeaderFactory);
bottle.serviceFactory('Home', () => Home);
bottle.decorator('Home', withoutSelectedServer);
bottle.decorator('Home', connect(['servers'], ['resetSelectedServer']));
bottle.serviceFactory('ShlinkWebComponent', () => ShlinkWebComponent);
bottle.factory('ShlinkWebComponentContainer', ShlinkWebComponentContainerFactory);
bottle.decorator('ShlinkWebComponentContainer', connect(['selectedServer', 'settings'], ['selectServer']));
bottle.serviceFactory('ShlinkVersionsContainer', () => ShlinkVersionsContainer);
bottle.decorator('ShlinkVersionsContainer', connect(['selectedServer']));
bottle.serviceFactory('ErrorHandler', () => ErrorHandler);
};

View File

@@ -1,63 +0,0 @@
import type { IContainer } from 'bottlejs';
import { type ComponentType, createContext, useContext } from 'react';
const ContainerContext = createContext<IContainer | null>(null);
export const ContainerProvider = ContainerContext.Provider;
const useContainer = (wrapperName: string): IContainer => {
const container = useContext(ContainerContext);
if (!container) {
throw new Error(`You cannot use "${wrapperName}" outside of a ContainerProvider`);
}
return container;
};
/**
* Hook used to extract dependencies from the container in other hooks.
*/
export const useDependencies = <T extends unknown[]>(...names: string[]): T => {
const container = useContainer('useDependencies');
return names.map((name) => {
const dependency = container[name];
if (!dependency) {
throw new Error(`Dependency with name "${name}" not found in container`);
}
return dependency;
}) as T;
};
type Optionalize<P, K extends keyof P> = Omit<P, K> & Partial<Pick<P, K>>;
/**
* Higher Order Component used to inject services into components as props.
* All dependencies become optional props so that they can still be explicitly set in tests if desired.
*/
export function withDependencies<
Props extends Record<string, unknown>,
DependencyName extends string & keyof Props,
>(
Component: ComponentType<Props>,
dependencyNames: DependencyName[],
): ComponentType<Optionalize<Props, DependencyName>> {
function Wrapper(props: Omit<Props, DependencyName>) {
const container = useContext(ContainerContext);
// Inject services, unless they have been overridden by props passed from
// the parent component.
const dependencies: Partial<Record<DependencyName, unknown>> = {};
for (const dependency of dependencyNames) {
if (!(dependency in props)) {
dependencies[dependency] = container?.[dependency];
}
}
const propsWithServices = { ...dependencies, ...props } as Props;
return <Component {...propsWithServices} />;
}
return Wrapper;
}

View File

@@ -1,32 +1,42 @@
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
import type { IContainer } from 'bottlejs';
import Bottle from 'bottlejs';
import { buildShlinkApiClient } from '../api/services/ShlinkApiClientBuilder';
import { ServersExporter } from '../servers/services/ServersExporter';
import { ServersImporter } from '../servers/services/ServersImporter';
import { csvToJson, jsonToCsv } from '../utils/helpers/csvjson';
import { LocalStorage } from '../utils/services/LocalStorage';
import { TagColorsStorage } from '../utils/services/TagColorsStorage';
import { connect as reduxConnect } from 'react-redux';
import { provideServices as provideApiServices } from '../api/services/provideServices';
import { provideServices as provideAppServices } from '../app/services/provideServices';
import { provideServices as provideCommonServices } from '../common/services/provideServices';
import { provideServices as provideServersServices } from '../servers/services/provideServices';
import { provideServices as provideSettingsServices } from '../settings/services/provideServices';
import { provideServices as provideUtilsServices } from '../utils/services/provideServices';
import type { ConnectDecorator } from './types';
type LazyActionMap = Record<string, (...args: unknown[]) => unknown>;
const bottle = new Bottle();
export const { container } = bottle;
bottle.constant('window', window);
bottle.constant('console', console);
bottle.constant('fetch', window.fetch.bind(window));
bottle.service('HttpClient', FetchHttpClient, 'fetch');
const lazyService = <T extends (...args: unknown[]) => unknown, K>(cont: IContainer, serviceName: string) =>
(...args: any[]) => (cont[serviceName] as T)(...args) as K;
bottle.constant('localStorage', window.localStorage);
bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
const mapActionService = (map: LazyActionMap, actionName: string): LazyActionMap => ({
...map,
// Wrap actual action service in a function so that it is lazily created the first time it is called
[actionName]: lazyService(container, actionName),
});
bottle.constant('csvToJson', csvToJson);
bottle.constant('jsonToCsv', jsonToCsv);
const pickProps = (propsToPick: string[]) => (obj: any) => Object.fromEntries(
propsToPick.map((key) => [key, obj[key]]),
);
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle);
const connect: ConnectDecorator = (propsFromState: string[] | null, actionServiceNames: string[] = []) =>
reduxConnect(
propsFromState ? pickProps(propsFromState) : null,
actionServiceNames.reduce(mapActionService, {}),
);
bottle.serviceFactory('buildShlinkApiClient', buildShlinkApiClient, 'HttpClient');
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
provideAppServices(bottle, connect);
provideCommonServices(bottle, connect);
provideApiServices(bottle);
provideServersServices(bottle, connect);
provideUtilsServices(bottle);
provideSettingsServices(bottle, connect);

25
src/container/store.ts Normal file
View File

@@ -0,0 +1,25 @@
import { configureStore } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import type { RLSOptions } from 'redux-localstorage-simple';
import { load, save } from 'redux-localstorage-simple';
import { initReducers } from '../reducers';
import { migrateDeprecatedSettings } from '../settings/helpers';
import type { ShlinkState } from './types';
const isProduction = process.env.NODE_ENV === 'production';
const localStorageConfig: RLSOptions = {
states: ['settings', 'servers'],
namespace: 'shlink',
namespaceSeparator: '.',
debounce: 300,
};
const preloadedState = migrateDeprecatedSettings(load(localStorageConfig) as ShlinkState);
export const setUpStore = (container: IContainer) => configureStore({
devTools: !isProduction,
reducer: initReducers(container),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these
.concat(save(localStorageConfig)),
});

13
src/container/types.ts Normal file
View File

@@ -0,0 +1,13 @@
import type { Settings } from '@shlinkio/shlink-web-component/settings';
import type { SelectedServer, ServersMap } from '../servers/data';
export interface ShlinkState {
servers: ServersMap;
selectedServer: SelectedServer;
settings: Settings;
appUpdated: boolean;
}
export type ConnectDecorator = (props: string[] | null, actions?: string[]) => any;
export type GetState = () => ShlinkState;

27
src/container/utils.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { IContainer } from 'bottlejs';
import type { FC } from 'react';
import { useMemo } from 'react';
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
}
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
Component: CompType,
deps: ReadonlyArray<keyof CompType>,
) {
return (container: IContainer, console = globalThis.console) => {
deps.forEach((dep) => {
const resolvedDependency = container[dep as string];
if (!resolvedDependency && process.env.NODE_ENV !== 'production') {
console.error(`[Debug] Could not find "${dep as string}" dependency in container`);
}
Component[dep] = resolvedDependency;
});
return Component;
};
}

6
src/index.scss Normal file
View File

@@ -0,0 +1,6 @@
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet
@use '../node_modules/bootstrap/scss/bootstrap.scss' with (
$primary: base.$mainColor // Override bootstrap's primary color
);
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overrides
@use '../node_modules/@shlinkio/shlink-web-component/dist/index' as c-index;

View File

@@ -2,30 +2,25 @@ import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router';
import pack from '../package.json';
import { App } from './app/App';
import { appUpdateAvailable } from './app/reducers/appUpdates';
import { ErrorHandler } from './common/ErrorHandler';
import { ScrollToTop } from './common/ScrollToTop';
import { container } from './container';
import { ContainerProvider } from './container/context';
import { setUpStore } from './container/store';
import { register as registerServiceWorker } from './serviceWorkerRegistration';
import { setUpStore } from './store';
import './tailwind.css';
import './index.scss';
const store = setUpStore();
const store = setUpStore(container);
const { App, ScrollToTop, ErrorHandler, appUpdateAvailable } = container;
createRoot(document.getElementById('root')!).render(
<ContainerProvider value={container}>
<Provider store={store}>
<BrowserRouter basename={pack.homepage}>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>
</ContainerProvider>,
<Provider store={store}>
<BrowserRouter basename={pack.homepage}>
<ErrorHandler>
<ScrollToTop>
<App />
</ScrollToTop>
</ErrorHandler>
</BrowserRouter>
</Provider>,
);
// Learn more about service workers: https://cra.link/PWA

View File

@@ -1,12 +1,12 @@
import { combineReducers } from '@reduxjs/toolkit';
import type { IContainer } from 'bottlejs';
import { appUpdatesReducer } from '../app/reducers/appUpdates';
import { selectedServerReducer } from '../servers/reducers/selectedServer';
import { serversReducer } from '../servers/reducers/servers';
import { settingsReducer } from '../settings/reducers/settings';
export const initReducers = () => combineReducers({
export const initReducers = (container: IContainer) => combineReducers({
appUpdated: appUpdatesReducer,
servers: serversReducer,
selectedServer: selectedServerReducer,
selectedServer: container.selectedServerReducer,
settings: settingsReducer,
});

View File

@@ -1,27 +1,34 @@
import type { ResultProps, TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { ResultProps } from '@shlinkio/shlink-frontend-kit/tailwind';
import { Button, Result } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { withDependencies } from '../container/context';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data';
import type { ServerData, ServersMap, ServerWithId } from './data';
import { ensureUniqueIds } from './helpers';
import { DuplicatedServersModal } from './helpers/DuplicatedServersModal';
import { ImportServersBtn } from './helpers/ImportServersBtn';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import { ServerForm } from './helpers/ServerForm';
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
import { useServers } from './reducers/servers';
const SHOW_IMPORT_MSG_TIME = 4000;
export type CreateServerProps = {
type CreateServerProps = {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type CreateServerDeps = {
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
};
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
<div className="mt-4">
<div className="tw:mt-4">
<Result variant={variant}>
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
@@ -29,14 +36,16 @@ const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
</div>
);
const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTimeoutToggle }) => {
const { servers, createServers } = useServers();
const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers, createServers }) => {
const { ImportServersBtn, useTimeoutToggle } = useDependencies(CreateServer);
const navigate = useNavigate();
const goBack = useGoBack();
const hasServers = !!Object.keys(servers).length;
const [serversImported, setServersImported] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
const { flag: isConfirmModalOpen, toggle: toggleConfirmModal } = useToggle();
// eslint-disable-next-line react-compiler/react-compiler
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
// eslint-disable-next-line react-compiler/react-compiler
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
const [serverData, setServerData] = useState<ServerData>();
const saveNewServer = useCallback((newServerData: ServerData) => {
const [newServerWithUniqueId] = ensureUniqueIds(servers, [newServerData]);
@@ -79,6 +88,6 @@ const CreateServerBase: FC<CreateServerProps> = withoutSelectedServer(({ useTime
/>
</NoMenuLayout>
);
});
};
export const CreateServer = withDependencies(CreateServerBase, ['useTimeoutToggle']);
export const CreateServerFactory = componentFactory(CreateServer, ['ImportServersBtn', 'useTimeoutToggle']);

View File

@@ -2,15 +2,22 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren } from 'react';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data';
import { DeleteServerModal } from './DeleteServerModal';
import type { DeleteServerModalProps } from './DeleteServerModal';
export type DeleteServerButtonProps = PropsWithChildren<{
server: ServerWithId;
}>;
export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, children }) => {
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
type DeleteServerButtonDeps = {
DeleteServerModal: FC<DeleteServerModalProps>;
};
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
const { DeleteServerModal } = useDependencies(DeleteServerButton);
const [isModalOpen, , showModal, hideModal] = useToggle();
const navigate = useNavigate();
const onClose = useCallback((confirmed: boolean) => {
hideModal();
@@ -21,10 +28,12 @@ export const DeleteServerButton: FC<DeleteServerButtonProps> = ({ server, childr
return (
<>
<button type="button" className="text-danger hover:underline" onClick={showModal}>
<button type="button" className="tw:text-danger tw:hover:underline" onClick={showModal}>
{children}
</button>
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
</>
);
};
export const DeleteServerButtonFactory = componentFactory(DeleteServerButton, ['DeleteServerModal']);

View File

@@ -1,9 +1,8 @@
import type { ExitAction } from '@shlinkio/shlink-frontend-kit';
import { CardModal } from '@shlinkio/shlink-frontend-kit';
import type { ExitAction } from '@shlinkio/shlink-frontend-kit/tailwind';
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useCallback } from 'react';
import type { ServerWithId } from './data';
import { useServers } from './reducers/servers';
export type DeleteServerModalProps = {
server: ServerWithId;
@@ -11,8 +10,11 @@ export type DeleteServerModalProps = {
open: boolean;
};
export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose, open }) => {
const { deleteServer } = useServers();
type DeleteServerModalConnectProps = DeleteServerModalProps & {
deleteServer: (server: ServerWithId) => void;
};
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
const onClosed = useCallback((exitAction: ExitAction) => {
if (exitAction === 'confirm') {
deleteServer(server);
@@ -29,7 +31,7 @@ export const DeleteServerModal: FC<DeleteServerModalProps> = ({ server, onClose,
onClosed={onClosed}
confirmText="Delete"
>
<div className="flex flex-col gap-y-4">
<div className="tw:flex tw:flex-col tw:gap-y-4">
<p>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
<p>
<i>

View File

@@ -1,17 +1,27 @@
import { Button, useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { useParsedQuery } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout';
import type { FCWithDeps } from '../container/utils';
import { componentFactory } from '../container/utils';
import { useGoBack } from '../utils/helpers/hooks';
import type { ServerData } from './data';
import { isServerWithId } from './data';
import { ServerForm } from './helpers/ServerForm';
import type { WithSelectedServerProps } from './helpers/withSelectedServer';
import { withSelectedServer } from './helpers/withSelectedServer';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const EditServer: FC = withSelectedServer(() => {
const { editServer } = useServers();
const { selectServer, selectedServer } = useSelectedServer();
type EditServerProps = WithSelectedServerProps & {
editServer: (serverId: string, serverData: ServerData) => void;
};
type EditServerDeps = {
ServerError: FC;
};
const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServer((
{ editServer, selectedServer, selectServer },
) => {
const goBack = useGoBack();
const { reconnect } = useParsedQuery<{ reconnect?: 'true' }>();
@@ -40,3 +50,5 @@ export const EditServer: FC = withSelectedServer(() => {
</NoMenuLayout>
);
});
export const EditServerFactory = componentFactory(EditServer, ['ServerError']);

View File

@@ -1,29 +1,37 @@
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit';
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useMemo, useState } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { withDependencies } from '../container/context';
import { ImportServersBtn } from './helpers/ImportServersBtn';
import { withoutSelectedServer } from './helpers/withoutSelectedServer';
import { ManageServersRow } from './ManageServersRow';
import { useServers } from './reducers/servers';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServersMap } from './data';
import type { ImportServersBtnProps } from './helpers/ImportServersBtn';
import type { ManageServersRowProps } from './ManageServersRow';
import type { ServersExporter } from './services/ServersExporter';
export type ManageServersProps = {
type ManageServersProps = {
servers: ServersMap;
};
type ManageServersDeps = {
ServersExporter: ServersExporter;
ImportServersBtn: FC<ImportServersBtnProps>;
useTimeoutToggle: TimeoutToggle;
ManageServersRow: FC<ManageServersRowProps>;
};
const SHOW_IMPORT_MSG_TIME = 4000;
const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
ServersExporter: serversExporter,
useTimeoutToggle,
}) => {
const { servers } = useServers();
const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ servers }) => {
const {
ServersExporter: serversExporter,
ImportServersBtn,
useTimeoutToggle,
ManageServersRow,
} = useDependencies(ManageServers);
const [searchTerm, setSearchTerm] = useState('');
const allServers = useMemo(() => Object.values(servers), [servers]);
const filteredServers = useMemo(
@@ -31,24 +39,24 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
[allServers, searchTerm],
);
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
const [errorImporting, setErrorImporting] = useTimeoutToggle({ delay: SHOW_IMPORT_MSG_TIME });
// eslint-disable-next-line react-compiler/react-compiler
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
return (
<NoMenuLayout className="flex flex-col gap-y-4">
<NoMenuLayout className="tw:flex tw:flex-col tw:gap-y-4">
<SearchInput onChange={setSearchTerm} />
<div className="flex flex-col md:flex-row gap-2">
<div className="flex gap-2">
<ImportServersBtn className="flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
<div className="tw:flex tw:gap-2">
<ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
{filteredServers.length > 0 && (
<Button variant="secondary" className="flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} widthAuto /> Export servers
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
<FontAwesomeIcon icon={exportIcon} /> Export servers
</Button>
)}
</div>
<Button className="md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} widthAuto /> Add a server
<Button className="tw:md:ml-auto" to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server
</Button>
</div>
@@ -56,7 +64,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
<Table header={(
<Table.Row>
{hasAutoConnect && (
<Table.Cell className="w-[35px]"><span className="sr-only">Auto-connect</span></Table.Cell>
<Table.Cell className="tw:w-[35px]"><span className="tw:sr-only">Auto-connect</span></Table.Cell>
)}
<Table.Cell>Name</Table.Cell>
<Table.Cell>Base URL</Table.Cell>
@@ -64,7 +72,7 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
</Table.Row>
)}>
{!filteredServers.length && (
<Table.Row className="text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
<Table.Row className="tw:text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
)}
{filteredServers.map((server) => (
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
@@ -79,6 +87,11 @@ const ManageServersBase: FC<ManageServersProps> = withoutSelectedServer(({
)}
</NoMenuLayout>
);
});
};
export const ManageServers = withDependencies(ManageServersBase, ['ServersExporter', 'useTimeoutToggle']);
export const ManageServersFactory = componentFactory(ManageServers, [
'ServersExporter',
'ImportServersBtn',
'useTimeoutToggle',
'ManageServersRow',
]);

View File

@@ -1,43 +1,49 @@
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Table, Tooltip, useTooltip } from '@shlinkio/shlink-frontend-kit';
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Link } from 'react-router';
import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data';
import { ManageServersRowDropdown } from './ManageServersRowDropdown';
import type { ManageServersRowDropdownProps } from './ManageServersRowDropdown';
export type ManageServersRowProps = {
server: ServerWithId;
hasAutoConnect: boolean;
};
export const ManageServersRow: FC<ManageServersRowProps> = ({ server, hasAutoConnect }) => {
const { anchor, tooltip } = useTooltip();
type ManageServersRowDeps = {
ManageServersRowDropdown: FC<ManageServersRowDropdownProps>;
};
const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps> = ({ server, hasAutoConnect }) => {
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
return (
<Table.Row className="relative">
<Table.Row className="tw:relative">
{hasAutoConnect && (
<Table.Cell columnName="Auto-connect">
{server.autoConnect && (
<>
<FontAwesomeIcon
icon={checkIcon}
className="text-lm-brand dark:text-dm-brand"
{...anchor}
data-testid="auto-connect"
/>
<Tooltip {...tooltip}>Auto-connect to this server</Tooltip>
<FontAwesomeIcon icon={checkIcon} className="tw:text-brand" id="autoConnectIcon" />
<UncontrolledTooltip target="autoConnectIcon" placement="right">
Auto-connect to this server
</UncontrolledTooltip>
</>
)}
</Table.Cell>
)}
<Table.Cell className="font-bold" columnName="Name">
<Table.Cell className="tw:font-bold" columnName="Name">
<Link to={`/server/${server.id}`}>{server.name}</Link>
</Table.Cell>
<Table.Cell columnName="Base URL" className="max-lg:border-b-0">{server.url}</Table.Cell>
<Table.Cell className="text-right max-lg:absolute right-0 -top-1 mx-lg:pt-0">
<Table.Cell columnName="Base URL" className="tw:max-lg:border-b-0">{server.url}</Table.Cell>
<Table.Cell className="tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0">
<ManageServersRowDropdown server={server} />
</Table.Cell>
</Table.Row>
);
};
export const ManageServersRowFactory = componentFactory(ManageServersRow, ['ManageServersRowDropdown']);

View File

@@ -6,42 +6,57 @@ import {
faPlug as connectIcon,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { RowDropdown, useToggle } from '@shlinkio/shlink-frontend-kit';
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router';
import { DropdownItem } from 'reactstrap';
import type { FCWithDeps } from '../container/utils';
import { componentFactory, useDependencies } from '../container/utils';
import type { ServerWithId } from './data';
import { DeleteServerModal } from './DeleteServerModal';
import { useServers } from './reducers/servers';
import type { DeleteServerModalProps } from './DeleteServerModal';
export type ManageServersRowDropdownProps = {
server: ServerWithId;
};
export const ManageServersRowDropdown: FC<ManageServersRowDropdownProps> = ({ server }) => {
const { setAutoConnect } = useServers();
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
type ManageServersRowDropdownConnectProps = ManageServersRowDropdownProps & {
setAutoConnect: (server: ServerWithId, autoConnect: boolean) => void;
};
type ManageServersRowDropdownDeps = {
DeleteServerModal: FC<DeleteServerModalProps>
};
const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps, ManageServersRowDropdownDeps> = (
{ server, setAutoConnect },
) => {
const { DeleteServerModal } = useDependencies(ManageServersRowDropdown);
const [isModalOpen,, showModal, hideModal] = useToggle();
const serverUrl = `/server/${server.id}`;
const { autoConnect: isAutoConnect } = server;
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
return (
<>
<RowDropdown menuAlignment="right">
<RowDropdown.Item to={serverUrl} className="gap-1.5">
<FontAwesomeIcon icon={connectIcon} /> Connect
</RowDropdown.Item>
<RowDropdown.Item to={`${serverUrl}/edit`} className="gap-1.5">
<FontAwesomeIcon icon={editIcon} /> Edit server
</RowDropdown.Item>
<RowDropdown.Item onClick={() => setAutoConnect(server, !isAutoConnect)} className="gap-1.5">
<FontAwesomeIcon icon={autoConnectIcon} /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</RowDropdown.Item>
<RowDropdown.Separator />
<RowDropdown.Item className="[&]:text-danger gap-1.5" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} /> Remove server
</RowDropdown.Item>
</RowDropdown>
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
<DropdownItem tag={Link} to={serverUrl}>
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
</DropdownItem>
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
</DropdownItem>
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
</DropdownItem>
<DropdownItem divider tag="hr" />
<DropdownItem className="tw:text-danger" onClick={showModal}>
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
</DropdownItem>
</RowDropdownBtn>
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
</>
);
};
export const ManageServersRowDropdownFactory = componentFactory(ManageServersRowDropdown, ['DeleteServerModal']);

View File

@@ -1,39 +1,42 @@
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dropdown, NavBar } from '@shlinkio/shlink-frontend-kit';
import type { FC } from 'react';
import { Link } from 'react-router';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import type { SelectedServer, ServersMap } from './data';
import { getServerId } from './data';
import { useSelectedServer } from './reducers/selectedServer';
import { useServers } from './reducers/servers';
export const ServersDropdown: FC = () => {
const { servers } = useServers();
export interface ServersDropdownProps {
servers: ServersMap;
selectedServer: SelectedServer;
}
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
const serversList = Object.values(servers);
const { selectedServer } = useSelectedServer();
return (
<NavBar.Dropdown buttonContent={(
<span className="flex items-center gap-1.5">
<FontAwesomeIcon icon={serverIcon} /> Servers
</span>
)}>
{serversList.length === 0 ? (
<Dropdown.Item to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> Add a server
</Dropdown.Item>
) : (
<>
{serversList.map(({ name, id }) => (
<Dropdown.Item key={id} to={`/server/${id}`} selected={getServerId(selectedServer) === id}>
{name}
</Dropdown.Item>
))}
<Dropdown.Separator />
<Dropdown.Item to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> Manage servers
</Dropdown.Item>
</>
)}
</NavBar.Dropdown>
<UncontrolledDropdown nav inNavbar>
<DropdownToggle nav caret>
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
</DropdownToggle>
<DropdownMenu end className="tw:right-0">
{serversList.length === 0 ? (
<DropdownItem tag={Link} to="/server/create">
<FontAwesomeIcon icon={plusIcon} /> <span className="tw:ml-1">Add a server</span>
</DropdownItem>
) : (
<>
{serversList.map(({ name, id }) => (
<DropdownItem key={id} tag={Link} to={`/server/${id}`} active={getServerId(selectedServer) === id}>
{name}
</DropdownItem>
))}
<DropdownItem divider tag="hr" />
<DropdownItem tag={Link} to="/manage-servers">
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Manage servers</span>
</DropdownItem>
</>
)}
</DropdownMenu>
</UncontrolledDropdown>
);
};

View File

@@ -15,27 +15,29 @@ const ServerListItem = ({ id, name }: { id: string; name: string }) => (
to={`/server/${id}`}
className={clsx(
'servers-list__server-item',
'flex items-center justify-between gap-x-2 px-4 py-3',
'rounded-none hover:bg-lm-secondary hover:dark:bg-dm-secondary',
'border-b last:border-0 border-lm-border dark:border-dm-border',
'tw:flex tw:items-center tw:justify-between tw:gap-x-2 tw:px-4 tw:py-3',
'tw:rounded-none tw:hover:bg-lm-secondary tw:hover:dark:bg-dm-secondary',
'tw:border-b tw:last:border-0 tw:border-lm-border tw:dark:border-dm-border',
)}
>
<span className="truncate">{name}</span>
<span className="tw:truncate">{name}</span>
<FontAwesomeIcon icon={chevronIcon} />
</Link>
);
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
servers.length > 0 && (
<div
data-testid="list"
className={clsx(
'w-full border-lm-border dark:border-dm-border',
'md:max-h-56 md:overflow-y-auto -mb-1 scroll-thin',
{ 'border-y': !borderless },
)}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</div>
)
<>
{servers.length > 0 && (
<div
data-testid="list"
className={clsx(
'tw:w-full tw:border-lm-border tw:dark:border-dm-border',
'tw:md:max-h-56 tw:md:overflow-y-auto tw:-mb-1 tw:scroll-thin',
{ 'tw:border-y': !borderless },
)}
>
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
</div>
)}
</>
);

View File

@@ -1,4 +1,4 @@
import { CardModal } from '@shlinkio/shlink-frontend-kit';
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Fragment } from 'react';
import type { ServerData } from '../data';
@@ -26,7 +26,7 @@ export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
>
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
<ul className="list-disc my-4 pl-5">
<ul className="tw:list-disc tw:mt-4">
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
<Fragment key={index}>
<li>URL: <b>{url}</b></li>

View File

@@ -1,11 +1,13 @@
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Button, Tooltip, useToggle, useTooltip } from '@shlinkio/shlink-frontend-kit';
import type { ChangeEvent, FC, PropsWithChildren } from 'react';
import { useCallback, useRef, useState } from 'react';
import { withDependencies } from '../../container/context';
import type { ServerData } from '../data';
import { useServers } from '../reducers/servers';
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { ChangeEvent, PropsWithChildren } from 'react';
import { useCallback, useRef , useState } from 'react';
import { UncontrolledTooltip } from 'reactstrap';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { ServerData, ServersMap, ServerWithId } from '../data';
import type { ServersImporter } from '../services/ServersImporter';
import { DuplicatedServersModal } from './DuplicatedServersModal';
import { dedupServers, ensureUniqueIds } from './index';
@@ -15,24 +17,30 @@ export type ImportServersBtnProps = PropsWithChildren<{
onError?: (error: Error) => void;
tooltipPlacement?: 'top' | 'bottom';
className?: string;
// Injected
ServersImporter: ServersImporter
}>;
const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
type ImportServersBtnConnectProps = ImportServersBtnProps & {
createServers: (servers: ServerWithId[]) => void;
servers: ServersMap;
};
type ImportServersBtnDeps = {
ServersImporter: ServersImporter
};
const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBtnDeps> = ({
createServers,
servers,
children,
onImport,
onError = () => {},
tooltipPlacement = 'bottom',
className = '',
ServersImporter: serversImporter,
}) => {
const { createServers, servers } = useServers();
const fileInputRef = useRef<HTMLInputElement>(null);
const { anchor, tooltip } = useTooltip({ placement: tooltipPlacement });
const { ServersImporter: serversImporter } = useDependencies(ImportServersBtn);
const ref = useElementRef<HTMLInputElement>();
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
const { flag: isModalOpen, setToTrue: showModal, setToFalse: hideModal } = useToggle();
const [isModalOpen,, showModal, hideModal] = useToggle();
const newServersCreatedRef = useRef(false);
const onFile = useCallback(
@@ -76,20 +84,20 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
return (
<>
<Button variant="secondary" className={className} onClick={() => fileInputRef.current?.click()} {...anchor}>
<FontAwesomeIcon icon={importIcon} widthAuto /> {children ?? 'Import from file'}
<Button variant="secondary" id="importBtn" className={className} onClick={() => ref.current?.click()}>
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
</Button>
<Tooltip {...tooltip}>
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
You can create servers by importing a CSV file with <b>name</b>, <b>apiKey</b> and <b>url</b> columns.
</Tooltip>
</UncontrolledTooltip>
<input
type="file"
accept=".csv"
className="hidden"
className="tw:hidden"
aria-hidden
tabIndex={-1}
ref={fileInputRef}
ref={ref as any /* TODO Remove After updating to React 19 */}
onChange={onFile}
data-testid="csv-file-input"
/>
@@ -104,4 +112,4 @@ const ImportServersBtnBase: FC<ImportServersBtnProps> = ({
);
};
export const ImportServersBtn = withDependencies(ImportServersBtnBase, ['ServersImporter']);
export const ImportServersBtnFactory = componentFactory(ImportServersBtn, ['ServersImporter']);

View File

@@ -1,21 +1,30 @@
import { Card, Message } from '@shlinkio/shlink-frontend-kit';
import { Card, Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { Link } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { componentFactory, useDependencies } from '../../container/utils';
import type { SelectedServer, ServersMap } from '../data';
import { isServerWithId } from '../data';
import { DeleteServerButton } from '../DeleteServerButton';
import { useSelectedServer } from '../reducers/selectedServer';
import { useServers } from '../reducers/servers';
import type { DeleteServerButtonProps } from '../DeleteServerButton';
import { ServersListGroup } from '../ServersListGroup';
export const ServerError: FC = () => {
const { servers } = useServers();
const { selectedServer } = useSelectedServer();
type ServerErrorProps = {
servers: ServersMap;
selectedServer: SelectedServer;
};
type ServerErrorDeps = {
DeleteServerButton: FC<DeleteServerButtonProps>;
};
const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, selectedServer }) => {
const { DeleteServerButton } = useDependencies(ServerError);
return (
<NoMenuLayout>
<div className="flex flex-col items-center gap-y-4 md:gap-y-8">
<Message className="w-full lg:w-[80%]" variant="error">
<div className="tw:flex tw:flex-col tw:items-center tw:gap-y-4 tw:md:gap-y-8">
<Message className="tw:w-full tw:lg:w-[80%]" variant="error">
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
{isServerWithId(selectedServer) && (
<>
@@ -25,16 +34,16 @@ export const ServerError: FC = () => {
)}
</Message>
<p className="text-xl">
<p className="tw:text-xl">
These are the Shlink servers currently configured. Choose one of
them or <Link to="/server/create">add a new one</Link>.
</p>
<Card className="w-full max-w-100 overflow-hidden">
<Card className="tw:w-full tw:max-w-100 tw:overflow-hidden">
<ServersListGroup borderless servers={Object.values(servers)} />
</Card>
{isServerWithId(selectedServer) && (
<p className="text-xl">
<p className="tw:text-xl">
Alternatively, if you think you may have misconfigured this server, you
can <DeleteServerButton server={selectedServer}>remove
it</DeleteServerButton> or&nbsp;
@@ -45,3 +54,5 @@ export const ServerError: FC = () => {
</NoMenuLayout>
);
};
export const ServerErrorFactory = componentFactory(ServerError, ['DeleteServerButton']);

View File

@@ -1,3 +1,4 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import {
Checkbox,
Details,
@@ -5,8 +6,7 @@ import {
LabelledInput,
LabelledRevealablePasswordInput,
SimpleCard,
useToggle,
} from '@shlinkio/shlink-frontend-kit';
} from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { useState } from 'react';
import { usePreventDefault } from '../../utils/utils';
@@ -24,12 +24,13 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle(
initialValues?.forwardCredentials ?? false,
true,
);
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
return (
<form name="serverForm" onSubmit={handleSubmit}>
<SimpleCard className="mb-4" bodyClassName="flex flex-col gap-y-3" title={title}>
<SimpleCard className="tw:mb-4" bodyClassName="tw:flex tw:flex-col tw:gap-y-3" title={title}>
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
<LabelledRevealablePasswordInput
@@ -39,25 +40,25 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
required
/>
<Details summary="Advanced options">
<div className="flex flex-col gap-0.5">
<Label className="flex items-center gap-x-1.5 cursor-pointer">
<div className="tw:flex tw:flex-col tw:gap-0.5">
<Label className="tw:flex tw:items-center tw:gap-x-1.5 tw:cursor-pointer">
<Checkbox onChange={toggleForwardCredentials} checked={forwardCredentials} />
Forward credentials to this server on every request.
</Label>
<small className="pl-5.5 text-gray-600 dark:text-gray-400 mt-0.5">
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400 tw:mt-0.5">
{'"'}Credentials{'"'} here means cookies, TLS client certificates, or authentication headers containing a username
and password.
</small>
<small className="pl-5.5 text-gray-600 dark:text-gray-400">
<small className="tw:pl-5.5 tw:text-gray-600 tw:dark:text-gray-400">
<b>Important!</b> If you are not sure what this means, leave it unchecked. Enabling this option will
make all requests fail for Shlink older than v4.5.0, as it requires the server to set a more strict
value for <code className="whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
value for <code className="tw:whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
</small>
</div>
</Details>
</SimpleCard>
<div className="flex items-center justify-end gap-x-2">{children}</div>
<div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>
</form>
);
};

View File

@@ -1,16 +1,29 @@
import { Message } from '@shlinkio/shlink-frontend-kit';
import { Message } from '@shlinkio/shlink-frontend-kit/tailwind';
import type { FC } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router';
import { NoMenuLayout } from '../../common/NoMenuLayout';
import type { FCWithDeps } from '../../container/utils';
import { useDependencies } from '../../container/utils';
import type { SelectedServer } from '../data';
import { isNotFoundServer } from '../data';
import { useSelectedServer } from '../reducers/selectedServer';
import { ServerError } from './ServerError';
export function withSelectedServer<T extends object>(WrappedComponent: FC<T>) {
const ComponentWrapper: FC<T> = (props) => {
export type WithSelectedServerProps = {
selectServer: (serverId: string) => void;
selectedServer: SelectedServer;
};
type WithSelectedServerPropsDeps = {
ServerError: FC;
};
export function withSelectedServer<T extends object>(
WrappedComponent: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps>,
) {
const ComponentWrapper: FCWithDeps<WithSelectedServerProps & T, WithSelectedServerPropsDeps> = (props) => {
const { ServerError } = useDependencies(ComponentWrapper);
const params = useParams<{ serverId: string }>();
const { selectServer, selectedServer } = useSelectedServer();
const { selectServer, selectedServer } = props;
useEffect(() => {
if (params.serverId) {

View File

@@ -1,10 +1,13 @@
import type { FC } from 'react';
import { useEffect } from 'react';
import { useSelectedServer } from '../reducers/selectedServer';
export function withoutSelectedServer<T extends object>(WrappedComponent: FC<T>) {
return (props: T) => {
const { resetSelectedServer } = useSelectedServer();
interface WithoutSelectedServerProps {
resetSelectedServer: () => unknown;
}
export function withoutSelectedServer<T extends object>(WrappedComponent: FC<WithoutSelectedServerProps & T>) {
return (props: WithoutSelectedServerProps & T) => {
const { resetSelectedServer } = props;
useEffect(() => {
resetSelectedServer();
}, [resetSelectedServer]);

View File

@@ -1,46 +1,21 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { useCallback, useEffect, useRef } from 'react';
import pack from '../../../package.json';
import { useDependencies } from '../../container/context';
import { useAppDispatch } from '../../store';
import { createAsyncThunk } from '../../store/helpers';
import { createAsyncThunk } from '../../utils/helpers/redux';
import { hasServerData } from '../data';
import { ensureUniqueIds } from '../helpers';
import { createServers, useServers } from './servers';
import { createServers } from './servers';
const responseToServersList = (data: any) => ensureUniqueIds(
{},
(Array.isArray(data) ? data.filter(hasServerData) : []),
);
export const fetchServers = createAsyncThunk(
export const fetchServers = (httpClient: HttpClient) => createAsyncThunk(
'shlink/remoteServers/fetchServers',
async (httpClient: HttpClient, { dispatch }): Promise<void> => {
async (_: void, { dispatch }): Promise<void> => {
const resp = await httpClient.jsonRequest<any>(`${pack.homepage}/servers.json`);
const result = responseToServersList(resp);
dispatch(createServers(result));
},
);
export const useRemoteServers = () => {
const dispatch = useAppDispatch();
const [httpClient] = useDependencies<[HttpClient]>('HttpClient');
const dispatchFetchServer = useCallback(() => dispatch(fetchServers(httpClient)), [dispatch, httpClient]);
return { fetchServers: dispatchFetchServer };
};
export const useLoadRemoteServers = () => {
const { fetchServers } = useRemoteServers();
const { servers } = useServers();
const initialServers = useRef(servers);
useEffect(() => {
// Try to fetch the remote servers if the list is empty during first render.
// We use a ref because we don't care if the servers list becomes empty later.
if (Object.keys(initialServers.current).length === 0) {
fetchServers();
}
}, [fetchServers]);
};

View File

@@ -1,11 +1,8 @@
import { createAction, createSlice } from '@reduxjs/toolkit';
import { memoizeWith } from '@shlinkio/data-manipulation';
import type { ShlinkHealth } from '@shlinkio/shlink-web-component/api-contract';
import { useCallback } from 'react';
import type { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { useDependencies } from '../../container/context';
import { useAppDispatch, useAppSelector } from '../../store';
import { createAsyncThunk } from '../../store/helpers';
import { createAsyncThunk } from '../../utils/helpers/redux';
import { versionToPrintable, versionToSemVer as toSemVer } from '../../utils/helpers/version';
import type { SelectedServer, ServerWithId } from '../data';
@@ -32,14 +29,9 @@ const initialState: SelectedServer = null;
export const resetSelectedServer = createAction<void>(`${REDUCER_PREFIX}/resetSelectedServer`);
export type SelectServerOptions = {
serverId: string;
buildShlinkApiClient: ShlinkApiClientBuilder;
};
export const selectServer = createAsyncThunk(
export const selectServer = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk(
`${REDUCER_PREFIX}/selectServer`,
async ({ serverId, buildShlinkApiClient }: SelectServerOptions, { dispatch, getState }): Promise<SelectedServer> => {
async (serverId: string, { dispatch, getState }): Promise<SelectedServer> => {
dispatch(resetSelectedServer());
const { servers } = getState();
@@ -64,29 +56,14 @@ export const selectServer = createAsyncThunk(
},
);
export const { reducer: selectedServerReducer } = createSlice({
type SelectServerThunk = ReturnType<typeof selectServer>;
export const selectedServerReducerCreator = (selectServerThunk: SelectServerThunk) => createSlice({
name: REDUCER_PREFIX,
initialState: initialState as SelectedServer,
initialState,
reducers: {},
extraReducers: (builder) => {
builder.addCase(resetSelectedServer, () => initialState);
builder.addCase(selectServer.fulfilled, (_, { payload }) => payload);
builder.addCase(selectServerThunk.fulfilled, (_, { payload }) => payload as any);
},
});
export const useSelectedServer = () => {
const dispatch = useAppDispatch();
const [buildShlinkApiClient] = useDependencies<[ShlinkApiClientBuilder]>('buildShlinkApiClient');
const dispatchResetSelectedServer = useCallback(() => dispatch(resetSelectedServer()), [dispatch]);
const dispatchSelectServer = useCallback(
(serverId: string) => dispatch(selectServer({ serverId, buildShlinkApiClient })),
[buildShlinkApiClient, dispatch],
);
const selectedServer = useAppSelector(({ selectedServer }) => selectedServer);
return {
selectedServer,
resetSelectedServer: dispatchResetSelectedServer,
selectServer: dispatchSelectServer,
};
};

View File

@@ -1,23 +1,21 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
import type { ServerData, ServersMap, ServerWithId } from '../data';
import { serversListToMap } from '../helpers';
type EditServer = {
interface EditServer {
serverId: string;
serverData: Partial<ServerData>;
};
}
type SetAutoConnect = {
interface SetAutoConnect {
serverId: string;
autoConnect: boolean;
};
}
const initialState: ServersMap = {};
export const { actions, reducer: serversReducer } = createSlice({
export const { actions, reducer } = createSlice({
name: 'shlink/servers',
initialState,
reducers: {
@@ -67,19 +65,4 @@ export const { actions, reducer: serversReducer } = createSlice({
export const { editServer, deleteServer, setAutoConnect, createServers } = actions;
export const useServers = () => {
const dispatch = useAppDispatch();
const servers = useAppSelector((state) => state.servers);
const editServer = useCallback(
(serverId: string, serverData: Partial<ServerData>) => dispatch(actions.editServer(serverId, serverData)),
[dispatch],
);
const deleteServer = useCallback((server: ServerWithId) => dispatch(actions.deleteServer(server)), [dispatch]);
const setAutoConnect = useCallback(
(serverData: ServerWithId, autoConnect: boolean) => dispatch(actions.setAutoConnect(serverData, autoConnect)),
[dispatch],
);
const createServers = useCallback((servers: ServerWithId[]) => dispatch(actions.createServers(servers)), [dispatch]);
return { servers, editServer, deleteServer, setAutoConnect, createServers };
};
export const serversReducer = reducer;

View File

@@ -0,0 +1,73 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { CreateServerFactory } from '../CreateServer';
import { DeleteServerButtonFactory } from '../DeleteServerButton';
import { DeleteServerModal } from '../DeleteServerModal';
import { EditServerFactory } from '../EditServer';
import { ImportServersBtnFactory } from '../helpers/ImportServersBtn';
import { ServerErrorFactory } from '../helpers/ServerError';
import { withoutSelectedServer } from '../helpers/withoutSelectedServer';
import { ManageServersFactory } from '../ManageServers';
import { ManageServersRowFactory } from '../ManageServersRow';
import { ManageServersRowDropdownFactory } from '../ManageServersRowDropdown';
import { fetchServers } from '../reducers/remoteServers';
import {
resetSelectedServer,
selectedServerReducerCreator,
selectServer,
} from '../reducers/selectedServer';
import { createServers, deleteServer, editServer, setAutoConnect } from '../reducers/servers';
import { ServersDropdown } from '../ServersDropdown';
import { ServersExporter } from './ServersExporter';
import { ServersImporter } from './ServersImporter';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.factory('ManageServers', ManageServersFactory);
bottle.decorator('ManageServers', withoutSelectedServer);
bottle.decorator('ManageServers', connect(['selectedServer', 'servers'], ['resetSelectedServer']));
bottle.factory('ManageServersRow', ManageServersRowFactory);
bottle.factory('ManageServersRowDropdown', ManageServersRowDropdownFactory);
bottle.decorator('ManageServersRowDropdown', connect(null, ['setAutoConnect']));
bottle.factory('CreateServer', CreateServerFactory);
bottle.decorator('CreateServer', withoutSelectedServer);
bottle.decorator('CreateServer', connect(['selectedServer', 'servers'], ['createServers', 'resetSelectedServer']));
bottle.factory('EditServer', EditServerFactory);
bottle.decorator('EditServer', connect(['selectedServer'], ['editServer', 'selectServer', 'resetSelectedServer']));
bottle.serviceFactory('ServersDropdown', () => ServersDropdown);
bottle.decorator('ServersDropdown', connect(['servers', 'selectedServer']));
bottle.serviceFactory('DeleteServerModal', () => DeleteServerModal);
bottle.decorator('DeleteServerModal', connect(null, ['deleteServer']));
bottle.factory('DeleteServerButton', DeleteServerButtonFactory);
bottle.factory('ImportServersBtn', ImportServersBtnFactory);
bottle.decorator('ImportServersBtn', connect(['servers'], ['createServers']));
bottle.factory('ServerError', ServerErrorFactory);
bottle.decorator('ServerError', connect(['servers', 'selectedServer']));
// Services
bottle.service('ServersImporter', ServersImporter, 'csvToJson');
bottle.service('ServersExporter', ServersExporter, 'Storage', 'window', 'jsonToCsv');
// Actions
bottle.serviceFactory('selectServer', selectServer, 'buildShlinkApiClient', 'loadMercureInfo');
bottle.serviceFactory('createServers', () => createServers);
bottle.serviceFactory('deleteServer', () => deleteServer);
bottle.serviceFactory('editServer', () => editServer);
bottle.serviceFactory('setAutoConnect', () => setAutoConnect);
bottle.serviceFactory('fetchServers', fetchServers, 'HttpClient');
bottle.serviceFactory('resetSelectedServer', () => resetSelectedServer);
// Reducers
bottle.serviceFactory('selectedServerReducerCreator', selectedServerReducerCreator, 'selectServer');
bottle.serviceFactory('selectedServerReducer', (obj) => obj.reducer, 'selectedServerReducerCreator');
};

View File

@@ -1,18 +1,20 @@
import type { Settings as AppSettings } from '@shlinkio/shlink-web-component/settings';
import { ShlinkWebSettings } from '@shlinkio/shlink-web-component/settings';
import type { FC } from 'react';
import { NoMenuLayout } from '../common/NoMenuLayout';
import { DEFAULT_SHORT_URLS_ORDERING, useSettings } from './reducers/settings';
import { DEFAULT_SHORT_URLS_ORDERING } from './reducers/settings';
export const Settings: FC = () => {
const { settings, setSettings } = useSettings();
return (
<NoMenuLayout>
<ShlinkWebSettings
settings={settings}
onUpdateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);
export type SettingsProps = {
settings: AppSettings;
setSettings: (newSettings: AppSettings) => void;
};
export const Settings: FC<SettingsProps> = ({ settings, setSettings }) => (
<NoMenuLayout>
<ShlinkWebSettings
settings={settings}
updateSettings={setSettings}
defaultShortUrlsListOrdering={DEFAULT_SHORT_URLS_ORDERING}
/>
</NoMenuLayout>
);

View File

@@ -1,6 +1,12 @@
export const migrateDeprecatedSettings = (state: any): any => {
import type { ShlinkState } from '../../container/types';
export const migrateDeprecatedSettings = (state: Partial<ShlinkState>): Partial<ShlinkState> => {
if (!state.settings) {
return state;
}
// The "last180Days" interval had a typo, with a lowercase d
if (state.settings?.visits?.defaultInterval === 'last180days') {
if (state.settings.visits && (state.settings.visits.defaultInterval as any) === 'last180days') {
state.settings.visits.defaultInterval = 'last180Days';
}

View File

@@ -3,8 +3,6 @@ import { createSlice } from '@reduxjs/toolkit';
import { mergeDeepRight } from '@shlinkio/data-manipulation';
import { getSystemPreferredTheme } from '@shlinkio/shlink-frontend-kit';
import type { Settings, ShortUrlsListSettings } from '@shlinkio/shlink-web-component/settings';
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../../store';
import type { Defined } from '../../utils/types';
type ShortUrlsOrder = Defined<ShortUrlsListSettings['defaultOrdering']>;
@@ -20,7 +18,9 @@ const initialState: Settings = {
realTimeUpdates: {
enabled: true,
},
shortUrlCreation: {},
shortUrlCreation: {
validateUrls: false,
},
ui: {
theme: getSystemPreferredTheme(),
},
@@ -43,11 +43,3 @@ const { reducer, actions } = createSlice({
export const { setSettings } = actions;
export const settingsReducer = reducer;
export const useSettings = () => {
const dispatch = useAppDispatch();
const setSettings = useCallback((settings: Settings) => dispatch(actions.setSettings(settings)), [dispatch]);
const settings = useAppSelector((state) => state.settings);
return { settings, setSettings };
};

View File

@@ -0,0 +1,15 @@
import type Bottle from 'bottlejs';
import type { ConnectDecorator } from '../../container/types';
import { withoutSelectedServer } from '../../servers/helpers/withoutSelectedServer';
import { setSettings } from '../reducers/settings';
import { Settings } from '../Settings';
export const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Components
bottle.serviceFactory('Settings', () => Settings);
bottle.decorator('Settings', withoutSelectedServer);
bottle.decorator('Settings', connect(['settings'], ['setSettings', 'resetSelectedServer']));
// Actions
bottle.serviceFactory('setSettings', () => setSettings);
};

View File

@@ -1,32 +0,0 @@
import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux';
import type { RLSOptions } from 'redux-localstorage-simple';
import { load, save } from 'redux-localstorage-simple';
import { migrateDeprecatedSettings } from '../settings/helpers';
import { initReducers } from './reducers';
const localStorageConfig: RLSOptions = {
states: ['settings', 'servers'],
namespace: 'shlink',
namespaceSeparator: '.',
debounce: 300,
};
const getStateFromLocalStorage = () => migrateDeprecatedSettings(load(localStorageConfig));
const isProduction = process.env.NODE_ENV === 'production';
export const setUpStore = (preloadedState = getStateFromLocalStorage()) => configureStore({
devTools: !isProduction,
reducer: initReducers(),
preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) =>
defaultMiddlewaresIncludingReduxThunk().concat(save(localStorageConfig)),
});
export type StoreType = ReturnType<typeof setUpStore>;
export type AppDispatch = StoreType['dispatch'];
export type GetState = StoreType['getState'];
export type RootState = ReturnType<GetState>;
// Typed versions of useDispatch() and useSelector()
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

View File

@@ -1,12 +1,23 @@
@import 'tailwindcss';
@import '@shlinkio/shlink-frontend-kit/tailwind.preset.css';
@import '@shlinkio/shlink-web-component/tailwind.preset.css';
@import 'tailwindcss' prefix(tw) important;
@source '../node_modules/@shlinkio/shlink-frontend-kit';
@source '../node_modules/@shlinkio/shlink-web-component';
@import '@shlinkio/shlink-frontend-kit/tailwind.preset.css';
@theme {
/* Override breakpoints with the values from bootstrap, to keep sizing until fully migrated */
--breakpoint-sm: 576px;
--breakpoint-md: 768px;
--breakpoint-lg: 992px;
--breakpoint-xl: 1200px;
--breakpoint-2xl: 1400px;
}
@layer base {
:root {
--header-height: 56px;
--footer-height: 2.3rem;
--footer-margin: .8rem;
/* Width of ShlinkWebComponent's side menu when not collapsed */
--aside-menu-width: 260px;
}
}

View File

@@ -1,10 +1,10 @@
import type { AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
import { createAsyncThunk as baseCreateAsyncThunk } from '@reduxjs/toolkit';
import type { RootState } from '.';
import type { ShlinkState } from '../../container/types';
export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: RootState, serializedErrorType: any }>,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState, serializedErrorType: any }>,
) => baseCreateAsyncThunk(
typePrefix,
payloadCreator,

View File

@@ -0,0 +1,16 @@
import { useTimeoutToggle } from '@shlinkio/shlink-frontend-kit';
import type Bottle from 'bottlejs';
import { csvToJson, jsonToCsv } from '../helpers/csvjson';
import { LocalStorage } from './LocalStorage';
import { TagColorsStorage } from './TagColorsStorage';
export const provideServices = (bottle: Bottle) => {
bottle.constant('localStorage', window.localStorage);
bottle.service('Storage', LocalStorage, 'localStorage');
bottle.service('TagColorsStorage', TagColorsStorage, 'Storage');
bottle.constant('csvToJson', csvToJson);
bottle.constant('jsonToCsv', jsonToCsv);
bottle.serviceFactory('useTimeoutToggle', () => useTimeoutToggle);
};

View File

@@ -0,0 +1,23 @@
import type { FC, PropsWithChildren } from 'react';
import { useMemo } from 'react';
import { MemoryRouter, Route, Routes } from 'react-router';
export type MemoryRouterWithParamsProps = PropsWithChildren<{
params: Record<string, string>;
}>;
/**
* Wrap any component using useParams() with MemoryRouterWithParams, in order to determine wat the hook should return
*/
export const MemoryRouterWithParams: FC<MemoryRouterWithParamsProps> = ({ children, params }) => {
const pathname = useMemo(() => `/${Object.values(params).join('/')}`, [params]);
const pathPattern = useMemo(() => `/:${Object.keys(params).join('/:')}`, [params]);
return (
<MemoryRouter>
<Routes location={{ pathname }}>
<Route path={pathPattern} element={children} />
</Routes>
</MemoryRouter>
);
};

View File

@@ -0,0 +1,8 @@
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
export const renderWithEvents = (element: ReactElement) => ({
user: userEvent.setup(),
...render(element),
});

View File

@@ -1,50 +0,0 @@
import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk';
import type { RenderOptions } from '@testing-library/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { fromPartial } from '@total-typescript/shoehorn';
import type { PropsWithChildren, ReactElement } from 'react';
import { Provider } from 'react-redux';
import { ContainerProvider } from '../../src/container/context';
import type { RootState } from '../../src/store';
import { setUpStore } from '../../src/store';
export const renderWithEvents = (element: ReactElement, options?: RenderOptions) => ({
user: userEvent.setup(),
...render(element, options),
});
export type RenderOptionsWithState = Omit<RenderOptions, 'wrapper'> & {
/** Initial state for the redux store */
initialState?: Partial<RootState>;
/**
* If provided, it will set this as the `buildShlinkApiClient` dependency in the `ContainerProvider`.
* If more dependencies are needed, then explicitly define your own `ContainerProvider` and make sure it includes a
* `buildShlinkApiClient` service.
*
* Defaults to vi.fn()
*/
buildShlinkApiClient?: () => ShlinkApiClient;
};
/**
* Render provided ReactElement wrapped in a redux `Provider` and a `ContainerProvider` with a single
* `buildShlinkApiClient` dependency.
*/
export const renderWithStore = (
element: ReactElement,
{ initialState = {}, buildShlinkApiClient = vi.fn(), ...options }: RenderOptionsWithState = {},
) => {
const store = setUpStore(initialState);
const Wrapper = ({ children }: PropsWithChildren) => (
<ContainerProvider value={fromPartial({ buildShlinkApiClient })}>
<Provider store={store}>{children}</Provider>
</ContainerProvider>
);
return {
store,
...renderWithEvents(element, { ...options, wrapper: Wrapper }),
};
};

View File

@@ -1,51 +1,50 @@
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
import { act, screen } from '@testing-library/react';
import { act, render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router';
import { App } from '../../src/app/App';
import { ContainerProvider } from '../../src/container/context';
import type { ServerWithId } from '../../src/servers/data';
import { AppFactory } from '../../src/app/App';
import { checkAccessibility } from '../__helpers__/accessibility';
import { renderWithStore } from '../__helpers__/setUpTest';
vi.mock(import('../../src/common/ShlinkWebComponentContainer'), () => ({
ShlinkWebComponentContainer: () => <span>ShlinkWebComponentContainer</span>,
}));
describe('<App />', () => {
const setUp = async (activeRoute = '/') => act(() => renderWithStore(
const App = AppFactory(
fromPartial({
MainHeader: () => <>MainHeader</>,
Home: () => <>Home</>,
ShlinkWebComponentContainer: () => <>ShlinkWebComponentContainer</>,
CreateServer: () => <>CreateServer</>,
EditServer: () => <>EditServer</>,
Settings: () => <>SettingsComp</>,
ManageServers: () => <>ManageServers</>,
ShlinkVersionsContainer: () => <>ShlinkVersions</>,
}),
);
const setUp = async (activeRoute = '/') => act(() => render(
<MemoryRouter initialEntries={[{ pathname: activeRoute }]}>
<ContainerProvider
value={fromPartial({
HttpClient: fromPartial<HttpClient>({}),
buildShlinkApiClient: vi.fn(),
useTimeoutToggle: vi.fn().mockReturnValue([false, vi.fn()]),
})}
>
<App />
</ContainerProvider>
<App
fetchServers={() => {}}
servers={{}}
settings={fromPartial({})}
appUpdated={false}
resetAppUpdate={() => {}}
/>
</MemoryRouter>,
{
initialState: {
servers: {
abc123: fromPartial<ServerWithId>({ id: 'abc123', name: 'abc123 server' }),
def456: fromPartial<ServerWithId>({ id: 'def456', name: 'def456 server' }),
},
settings: fromPartial({}),
appUpdated: false,
},
},
));
it('passes a11y checks', () => checkAccessibility(setUp()));
it('renders children components', async () => {
await setUp();
expect(screen.getByText('MainHeader')).toBeInTheDocument();
expect(screen.getByText('ShlinkVersions')).toBeInTheDocument();
});
it.each([
['/settings/general', 'User interface'],
['/settings/short-urls', 'Short URLs form'],
['/manage-servers', 'Add a server'],
['/server/create', 'Add new server'],
['/server/abc123/edit', 'Edit "abc123 server"'],
['/server/def456/edit', 'Edit "def456 server"'],
['/settings/foo', 'SettingsComp'],
['/settings/bar', 'SettingsComp'],
['/manage-servers', 'ManageServers'],
['/server/create', 'CreateServer'],
['/server/abc123/edit', 'EditServer'],
['/server/def456/edit', 'EditServer'],
['/server/abc123/foo', 'ShlinkWebComponentContainer'],
['/server/def456/bar', 'ShlinkWebComponentContainer'],
['/other', 'Oops! We could not find requested route.'],
@@ -63,9 +62,9 @@ describe('<App />', () => {
const shlinkWrapper = screen.getByTestId('shlink-wrapper');
if (isFlex) {
expect(shlinkWrapper).toHaveClass('flex');
expect(shlinkWrapper).toHaveClass('tw:flex');
} else {
expect(shlinkWrapper).not.toHaveClass('flex');
expect(shlinkWrapper).not.toHaveClass('tw:flex');
}
});
});

Some files were not shown because too many files have changed in this diff Show More