mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2026-03-03 06:11:48 +00:00
Compare commits
260 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b747e63d51 | ||
|
|
557cd4f7e2 | ||
|
|
b0189c6457 | ||
|
|
8da630e149 | ||
|
|
d65eafd37f | ||
|
|
4895cbb9dc | ||
|
|
1467c8e416 | ||
|
|
e12cd68010 | ||
|
|
e997d11c2c | ||
|
|
4947e0490a | ||
|
|
c68a7ee22a | ||
|
|
c4d99606cb | ||
|
|
5e3e70454d | ||
|
|
860808a654 | ||
|
|
476c98d4f3 | ||
|
|
35a3224f70 | ||
|
|
0c001a6d81 | ||
|
|
7a9ab1c803 | ||
|
|
eb6ba5f15d | ||
|
|
52cde329d7 | ||
|
|
aae2cd6866 | ||
|
|
becdf04acf | ||
|
|
e3c853361d | ||
|
|
385fc839b5 | ||
|
|
5d72d36c20 | ||
|
|
1fcd2fffd0 | ||
|
|
7cdc84b45d | ||
|
|
3f317d2559 | ||
|
|
788df59ae1 | ||
|
|
d57188addf | ||
|
|
778f7cedbb | ||
|
|
f71218998f | ||
|
|
d1d6a6b373 | ||
|
|
41de28c15f | ||
|
|
c1bbeeb6ac | ||
|
|
9f3d7df5cd | ||
|
|
a7d6637a81 | ||
|
|
14fa2e32ea | ||
|
|
c08f0dad8b | ||
|
|
43853ef14f | ||
|
|
b831c6c3bd | ||
|
|
1f51d60ed2 | ||
|
|
600a3e2045 | ||
|
|
5458657c8b | ||
|
|
603ee9d6bf | ||
|
|
3405f524df | ||
|
|
1fbbef41a7 | ||
|
|
eef04075a5 | ||
|
|
7880bb4abe | ||
|
|
ac0c6aa729 | ||
|
|
691e6c1afb | ||
|
|
09559c78af | ||
|
|
3d6ea5cf7c | ||
|
|
0a7a606541 | ||
|
|
61cc6c8d26 | ||
|
|
5e0db07ef3 | ||
|
|
d188d67c5a | ||
|
|
bd034c11b6 | ||
|
|
0ec867b185 | ||
|
|
c29b077e93 | ||
|
|
d8a42b4c3a | ||
|
|
7879476739 | ||
|
|
fd40e2b7bc | ||
|
|
aefe5e0848 | ||
|
|
fd7bfd845e | ||
|
|
01ca369388 | ||
|
|
15ef29ecea | ||
|
|
b19162ce91 | ||
|
|
7c31b210bd | ||
|
|
a63c214d8d | ||
|
|
c9ada8f41d | ||
|
|
06fac716d1 | ||
|
|
c462bc30e1 | ||
|
|
ad00e54df8 | ||
|
|
ca4543b227 | ||
|
|
ddca307001 | ||
|
|
dd651eb324 | ||
|
|
6ea7347ae1 | ||
|
|
b4810aa4cd | ||
|
|
8a99076a21 | ||
|
|
432bbcb4ac | ||
|
|
0f006a737f | ||
|
|
73d9a535bc | ||
|
|
da5eddaa6a | ||
|
|
d2362d4ddd | ||
|
|
18e41b3064 | ||
|
|
148dff996c | ||
|
|
20d12c4d7c | ||
|
|
f25389a638 | ||
|
|
a10e9646e6 | ||
|
|
8a697bb7d2 | ||
|
|
834eca8461 | ||
|
|
b5347b1801 | ||
|
|
939d9d4463 | ||
|
|
56aee7e96e | ||
|
|
49ab0d425d | ||
|
|
9227b00d1a | ||
|
|
a736bb3b21 | ||
|
|
ecbc0f64d6 | ||
|
|
a522a74199 | ||
|
|
bee4094b84 | ||
|
|
e3e909a023 | ||
|
|
d91d341bbf | ||
|
|
a3d85a7454 | ||
|
|
a5a5b0c3a4 | ||
|
|
50e7204a4f | ||
|
|
0c60b8710e | ||
|
|
bb92930116 | ||
|
|
c1483d4c99 | ||
|
|
aeedd45bb6 | ||
|
|
861cb6efc6 | ||
|
|
a8f4b47803 | ||
|
|
437d769527 | ||
|
|
8dfed49dcf | ||
|
|
794a0a1b14 | ||
|
|
86bd650213 | ||
|
|
c4cb462a5e | ||
|
|
3aecc4fd5b | ||
|
|
7c550af923 | ||
|
|
6588a0eede | ||
|
|
7b219158e1 | ||
|
|
1c7ab0e1ea | ||
|
|
148aa30915 | ||
|
|
781d9a0355 | ||
|
|
6a5c6817d0 | ||
|
|
3d3a292284 | ||
|
|
5bf9547e55 | ||
|
|
45078bb632 | ||
|
|
87060410b5 | ||
|
|
0bc898c999 | ||
|
|
94e188c6c0 | ||
|
|
8a74b3d736 | ||
|
|
897d93b9ab | ||
|
|
f7b0f692d9 | ||
|
|
80c0b84785 | ||
|
|
dc685084d1 | ||
|
|
763c767286 | ||
|
|
01302cf25e | ||
|
|
6dee9260c1 | ||
|
|
8f1847e38b | ||
|
|
176e0b4961 | ||
|
|
de9be5fcf4 | ||
|
|
b46db36a1f | ||
|
|
8dd9287eaf | ||
|
|
ab784515c0 | ||
|
|
337c8c1ee2 | ||
|
|
b46e637a64 | ||
|
|
b2d8cdb43f | ||
|
|
23ae01d34d | ||
|
|
306c496489 | ||
|
|
13e57eb2ae | ||
|
|
c4ecdc9175 | ||
|
|
2e99ee43ce | ||
|
|
4c7c457839 | ||
|
|
9b84df6774 | ||
|
|
9d010f8996 | ||
|
|
21b7ca1a0a | ||
|
|
26578ed55d | ||
|
|
1b6369264b | ||
|
|
7c0033397b | ||
|
|
49a75a08aa | ||
|
|
4dad18e45f | ||
|
|
0016333972 | ||
|
|
b65291bb01 | ||
|
|
dac8a08fd2 | ||
|
|
c18808503c | ||
|
|
06563feffc | ||
|
|
468a529f94 | ||
|
|
0c568a327f | ||
|
|
2e9db77b00 | ||
|
|
c85917e378 | ||
|
|
d2d9810afd | ||
|
|
db13d43f99 | ||
|
|
1acc64c074 | ||
|
|
32c4cd40d3 | ||
|
|
abf02ddbac | ||
|
|
552cfcf805 | ||
|
|
4198ad73de | ||
|
|
b6a49e3b11 | ||
|
|
e716624a3f | ||
|
|
6a8825ecb7 | ||
|
|
e171866226 | ||
|
|
c98616234f | ||
|
|
4fa9f8967b | ||
|
|
5ea42b6c15 | ||
|
|
d0bc6610b1 | ||
|
|
995769de27 | ||
|
|
aeb4f8ed9a | ||
|
|
44e1bef29b | ||
|
|
b472b44c5b | ||
|
|
227b762740 | ||
|
|
c0765eeb92 | ||
|
|
da1b727788 | ||
|
|
2875a857a5 | ||
|
|
81952acb84 | ||
|
|
33fd901552 | ||
|
|
394ae44401 | ||
|
|
5e3aaca8d8 | ||
|
|
1647ee023a | ||
|
|
a95451d12c | ||
|
|
c6c1caa420 | ||
|
|
14b2f3a9f6 | ||
|
|
022fe6af3a | ||
|
|
ed827c279a | ||
|
|
aede99b709 | ||
|
|
281fa9ebdd | ||
|
|
b7c2a423c8 | ||
|
|
a39e841af2 | ||
|
|
00384abe9c | ||
|
|
4b1b107f44 | ||
|
|
4175bd3a5e | ||
|
|
15aeb5c922 | ||
|
|
168f8d85ce | ||
|
|
5fc42d3850 | ||
|
|
64d7d16ea2 | ||
|
|
fa9de43123 | ||
|
|
87618181ab | ||
|
|
03cf9dbe79 | ||
|
|
4afc621d10 | ||
|
|
4f1d7fbd5f | ||
|
|
c5b7116992 | ||
|
|
b08610196c | ||
|
|
3ad070ea18 | ||
|
|
94bc49a0b3 | ||
|
|
9269593c46 | ||
|
|
baa19b5c4c | ||
|
|
4fc48d3259 | ||
|
|
03c0f4649f | ||
|
|
0280fc0f59 | ||
|
|
d0b48c90a6 | ||
|
|
e90ec0b27c | ||
|
|
fc66171a09 | ||
|
|
769f7f45ee | ||
|
|
ca89e8e4f9 | ||
|
|
d7dda228b6 | ||
|
|
28136a656f | ||
|
|
4502ca5543 | ||
|
|
b4decf9124 | ||
|
|
c5d12a49f4 | ||
|
|
dcab31b30c | ||
|
|
c917d643d2 | ||
|
|
8c2ca8656b | ||
|
|
9a22d51996 | ||
|
|
d95352f467 | ||
|
|
8dbfffc383 | ||
|
|
1606ce357e | ||
|
|
096d27f107 | ||
|
|
8cc03a973f | ||
|
|
f4c4f408e2 | ||
|
|
a65c7171c1 | ||
|
|
d14c4fa606 | ||
|
|
7e4c915b0b | ||
|
|
a5a15963b8 | ||
|
|
88515fc571 | ||
|
|
b20e8ae528 | ||
|
|
dcbbe9a9a6 | ||
|
|
61cabaa356 | ||
|
|
30f0df5a10 | ||
|
|
37562c06ec | ||
|
|
478fc2c274 |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -21,9 +21,12 @@ updates:
|
|||||||
shlink:
|
shlink:
|
||||||
patterns:
|
patterns:
|
||||||
- '@shlinkio/*'
|
- '@shlinkio/*'
|
||||||
types:
|
react:
|
||||||
patterns:
|
patterns:
|
||||||
- '@types/*'
|
- 'react'
|
||||||
|
- 'react-dom'
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
testing:
|
testing:
|
||||||
patterns:
|
patterns:
|
||||||
- '@testing-library/*'
|
- '@testing-library/*'
|
||||||
@@ -38,6 +41,10 @@ updates:
|
|||||||
workbox:
|
workbox:
|
||||||
patterns:
|
patterns:
|
||||||
- 'workbox*'
|
- 'workbox*'
|
||||||
|
tailwindcss:
|
||||||
|
patterns:
|
||||||
|
- 'tailwindcss'
|
||||||
|
- '@tailwindcss/*'
|
||||||
ignore:
|
ignore:
|
||||||
# Bootstrap can introduce visual breaking changes on styles
|
# Bootstrap can introduce visual breaking changes on styles
|
||||||
# Ignore it, since the plan is to remove it anyway
|
# Ignore it, since the plan is to remove it anyway
|
||||||
|
|||||||
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -13,3 +13,4 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.x
|
||||||
publish-coverage: true
|
publish-coverage: true
|
||||||
|
install-playwright: true
|
||||||
|
|||||||
4
.github/workflows/deploy-preview.yml
vendored
4
.github/workflows/deploy-preview.yml
vendored
@@ -5,7 +5,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -16,7 +16,7 @@ jobs:
|
|||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.10
|
||||||
- name: Build
|
- name: Build
|
||||||
run: |
|
run: |
|
||||||
npm ci && \
|
npm ci && \
|
||||||
|
|||||||
4
.github/workflows/publish-release.yml
vendored
4
.github/workflows/publish-release.yml
vendored
@@ -7,14 +7,14 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
- name: Use node.js
|
- name: Use node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22.x
|
node-version: 22.10
|
||||||
- name: Generate release assets
|
- name: Generate release assets
|
||||||
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
run: npm ci && VERSION=${GITHUB_REF#refs/tags/v} npm run build:dist
|
||||||
- name: Publish release with assets
|
- name: Publish release with assets
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": [
|
|
||||||
"@shlinkio/stylelint-config-css-coding-standard"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
27
CHANGELOG.md
27
CHANGELOG.md
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
|
## [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
|
||||||
|
* [shlink-web-component#637](https://github.com/shlinkio/shlink-web-component/pull/637) QR codes are now generated client-side, without hitting Shlink.
|
||||||
|
* [shlink-web-component#641](https://github.com/shlinkio/shlink-web-component/issues/641) It is now possible to provide any logo to be used with QR codes.
|
||||||
|
* [shlink-web-component#640](https://github.com/shlinkio/shlink-web-component/issues/640) Allow default QR code settings to be handled via app settings.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
* Update to `react-router` 7.0
|
||||||
|
* Update to `@shlinkio/shlink-frontend-kit` 0.8.x
|
||||||
|
* Update to `@shlinkio/shlink-web-component` 0.13.x
|
||||||
|
* Update to `@shlinkio/shlink-js-sdk` 2.0.0
|
||||||
|
* Add `eslint-plugin-react-compiler`
|
||||||
|
* Run unit tests in a headless browser using vitest browser mode and playwright.
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* *Nothing*
|
||||||
|
|
||||||
|
|
||||||
## [4.3.0] - 2024-11-30
|
## [4.3.0] - 2024-11-30
|
||||||
### Added
|
### Added
|
||||||
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
* [#1360](https://github.com/shlinkio/shlink-web-client/issues/1360) Added ability for server IDs to be generated based on the server name and URL, instead of generating a random UUID.
|
||||||
@@ -298,7 +323,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||||||
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
* [#774](https://github.com/shlinkio/shlink-web-client/issues/774) Dropped support for Shlink older than 2.8.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on miss-configured servers, after editing their params to set proper values.
|
* [#715](https://github.com/shlinkio/shlink-web-client/issues/715) Fixed connection still failing on misconfigured servers, after editing their params to set proper values.
|
||||||
|
|
||||||
|
|
||||||
## [3.8.2] - 2022-12-17
|
## [3.8.2] - 2022-12-17
|
||||||
|
|||||||
@@ -45,11 +45,8 @@ shlink-web-client
|
|||||||
|
|
||||||
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
> Note: The `indocker` shell script is a helper used to run commands inside the docker container.
|
||||||
|
|
||||||
* `./indocker node --run lint`: Checks coding styles are fulfilled, both in JS/TS files and in stylesheets.
|
* `./indocker node --run lint`: Checks coding styles are fulfilled in JS/TS files.
|
||||||
* `./indocker node --run lint:js`: Checks coding styles are fulfilled in JS/TS files.
|
* `./indocker node --run lint:fix`: Fixes coding styles in JS/TS files.
|
||||||
* `./indocker node --run lint:css`: Checks coding styles are fulfilled in stylesheets.
|
|
||||||
* `./indocker node --run lint:js:fix`: Fixes coding styles in JS/TS files.
|
|
||||||
* `./indocker node --run lint:css:fix`: Fixes coding styles in stylesheets.
|
|
||||||
* `./indocker node --run test`: Runs unit tests with Jest.
|
* `./indocker node --run test`: Runs unit tests with Jest.
|
||||||
|
|
||||||
## Building the project
|
## Building the project
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM node:23.3-alpine AS node
|
FROM node:23.11-alpine AS node
|
||||||
COPY . /shlink-web-client
|
COPY . /shlink-web-client
|
||||||
ARG VERSION="latest"
|
ARG VERSION="latest"
|
||||||
ENV VERSION=${VERSION}
|
ENV VERSION=${VERSION}
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
import '@testing-library/jest-dom/vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import axe from 'axe-core';
|
|
||||||
import { afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
|
|
||||||
axe.configure({
|
|
||||||
checks: [
|
|
||||||
{
|
|
||||||
// Disable color contrast checking, as it doesn't work in jsdom
|
|
||||||
id: 'color-contrast',
|
|
||||||
enabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clear all mocks and cleanup DOM after every test
|
// Clear all mocks and cleanup DOM after every test
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = (() => {}) as any;
|
|
||||||
(global as any).scrollTo = () => {};
|
|
||||||
(global as any).matchMedia = () => ({ matches: false });
|
|
||||||
|
|||||||
15
dev.Dockerfile
Normal file
15
dev.Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM mcr.microsoft.com/playwright:v1.52.0-noble
|
||||||
|
|
||||||
|
ENV NODE_VERSION 22.14
|
||||||
|
ENV TINI_VERSION v0.19.0
|
||||||
|
|
||||||
|
# Install Node.js
|
||||||
|
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash && \
|
||||||
|
\. "$HOME/.nvm/nvm.sh" && \
|
||||||
|
nvm install ${NODE_VERSION}
|
||||||
|
|
||||||
|
# Install tini
|
||||||
|
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /sbin/tini
|
||||||
|
RUN chmod +x /sbin/tini
|
||||||
|
# Set tini as the entry point, as node does not properly handle signals
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
@@ -2,7 +2,9 @@ services:
|
|||||||
shlink_web_client_node:
|
shlink_web_client_node:
|
||||||
container_name: shlink_web_client_node
|
container_name: shlink_web_client_node
|
||||||
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
|
user: 1000:1000 # With this, files created via `indocker` script will belong to the host user
|
||||||
image: node:22.10-alpine
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./dev.Dockerfile
|
||||||
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
command: /bin/sh -c "cd /home/shlink/www && npm install && npm run start"
|
||||||
volumes:
|
volumes:
|
||||||
- ./:/home/shlink/www
|
- ./:/home/shlink/www
|
||||||
|
|||||||
10473
package-lock.json
generated
10473
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
76
package.json
76
package.json
@@ -7,12 +7,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "node --run lint:css && node --run lint:js",
|
"lint": "eslint src test config/test",
|
||||||
"lint:css": "stylelint src/*.scss src/**/*.scss",
|
"lint:fix": "node --run lint -- --fix",
|
||||||
"lint:js": "eslint src test config/test",
|
|
||||||
"lint:fix": "node --run lint:css:fix && node --run lint:js:fix",
|
|
||||||
"lint:css:fix": "node --run lint:css -- --fix",
|
|
||||||
"lint:js:fix": "node --run lint:js -- --fix",
|
|
||||||
"types": "tsc",
|
"types": "tsc",
|
||||||
"start": "vite serve --host=0.0.0.0",
|
"start": "vite serve --host=0.0.0.0",
|
||||||
"preview": "vite preview --host=0.0.0.0",
|
"preview": "vite preview --host=0.0.0.0",
|
||||||
@@ -24,18 +20,18 @@
|
|||||||
"test:verbose": "node --run test -- --verbose"
|
"test:verbose": "node --run test -- --verbose"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^6.7.1",
|
"@fortawesome/fontawesome-free": "^6.7.2",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.7.1",
|
"@fortawesome/fontawesome-svg-core": "^6.7.2",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.7.1",
|
"@fortawesome/free-brands-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.7.1",
|
"@fortawesome/free-regular-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.7.1",
|
"@fortawesome/free-solid-svg-icons": "^6.7.2",
|
||||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||||
"@json2csv/plainjs": "^7.0.6",
|
"@json2csv/plainjs": "^7.0.6",
|
||||||
"@reduxjs/toolkit": "^2.4.0",
|
"@reduxjs/toolkit": "^2.7.0",
|
||||||
"@shlinkio/data-manipulation": "^1.0.3",
|
"@shlinkio/data-manipulation": "^1.0.3",
|
||||||
"@shlinkio/shlink-frontend-kit": "^0.6.0",
|
"@shlinkio/shlink-frontend-kit": "^0.8.12",
|
||||||
"@shlinkio/shlink-js-sdk": "^1.3.0",
|
"@shlinkio/shlink-js-sdk": "^2.1.0",
|
||||||
"@shlinkio/shlink-web-component": "^0.11.0",
|
"@shlinkio/shlink-web-component": "^0.13.3",
|
||||||
"bootstrap": "5.2.3",
|
"bootstrap": "5.2.3",
|
||||||
"bottlejs": "^2.0.1",
|
"bottlejs": "^2.0.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -44,9 +40,9 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-external-link": "^2.3.1",
|
"react-external-link": "^2.5.0",
|
||||||
"react-redux": "^9.1.2",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^6.28.0",
|
"react-router": "^7.5.1",
|
||||||
"reactstrap": "^9.2.3",
|
"reactstrap": "^9.2.3",
|
||||||
"redux-localstorage-simple": "^2.5.1",
|
"redux-localstorage-simple": "^2.5.1",
|
||||||
"workbox-core": "^7.3.0",
|
"workbox-core": "^7.3.0",
|
||||||
@@ -56,35 +52,37 @@
|
|||||||
"workbox-strategies": "^7.3.0"
|
"workbox-strategies": "^7.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@shlinkio/eslint-config-js-coding-standard": "~3.2.1",
|
"@shlinkio/eslint-config-js-coding-standard": "~3.5.0",
|
||||||
"@shlinkio/stylelint-config-css-coding-standard": "~1.1.1",
|
"@stylistic/eslint-plugin": "^4.2.0",
|
||||||
"@stylistic/eslint-plugin": "^2.11.0",
|
"@tailwindcss/vite": "^4.1.4",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.0.1",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^14.5.2",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@total-typescript/shoehorn": "^0.1.2",
|
"@total-typescript/shoehorn": "^0.1.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@types/uuid": "^10.0.0",
|
"@vitejs/plugin-react": "^4.4.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitest/browser": "^3.1.1",
|
||||||
"@vitest/coverage-v8": "^2.1.6",
|
"@vitest/coverage-v8": "^3.1.1",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"axe-core": "^4.10.2",
|
"axe-core": "^4.10.3",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.4.1",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||||
"eslint-plugin-react": "^7.37.2",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"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",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"jsdom": "^25.0.1",
|
"playwright": "^1.52.0",
|
||||||
"sass": "^1.81.0",
|
"sass": "^1.86.3",
|
||||||
"stylelint": "^15.11.0",
|
"tailwindcss": "^4.1.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.16.0",
|
"typescript-eslint": "^8.30.1",
|
||||||
"vite": "^6.0.1",
|
"vite": "^6.3.2",
|
||||||
"vite-plugin-pwa": "^0.21.1",
|
"vite-plugin-pwa": "^1.0.0",
|
||||||
"vitest": "^2.0.2"
|
"vitest": "^3.0.5"
|
||||||
},
|
},
|
||||||
"browserslist": [
|
"browserslist": [
|
||||||
">0.2%",
|
">0.2%",
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ setup_single_shlink_server() {
|
|||||||
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
[ -n "$SHLINK_SERVER_URL" ] || return 0
|
||||||
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
[ -n "$SHLINK_SERVER_API_KEY" ] || return 0
|
||||||
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
local name="${SHLINK_SERVER_NAME:-Shlink}"
|
||||||
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\"}]" > /usr/share/nginx/html/servers.json
|
local forwardCredentials="${SHLINK_SERVER_FORWARD_CREDENTIALS:-false}"
|
||||||
|
echo "[{\"name\":\"${name}\",\"url\":\"${SHLINK_SERVER_URL}\",\"apiKey\":\"${SHLINK_SERVER_API_KEY}\",\"forwardCredentials\":${forwardCredentials}}]" > /usr/share/nginx/html/servers.json
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_single_shlink_server
|
setup_single_shlink_server
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { GetState } from '../../container/types';
|
|||||||
import type { ServerWithId } from '../../servers/data';
|
import type { ServerWithId } from '../../servers/data';
|
||||||
import { hasServerData } from '../../servers/data';
|
import { hasServerData } from '../../servers/data';
|
||||||
|
|
||||||
const apiClients: Record<string, ShlinkApiClient> = {};
|
const apiClients: Map<string, ShlinkApiClient> = new Map();
|
||||||
|
|
||||||
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
const isGetState = (getStateOrSelectedServer: GetState | ServerWithId): getStateOrSelectedServer is GetState =>
|
||||||
typeof getStateOrSelectedServer === 'function';
|
typeof getStateOrSelectedServer === 'function';
|
||||||
@@ -18,14 +18,22 @@ const getSelectedServerFromState = (getState: GetState): ServerWithId => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
export const buildShlinkApiClient = (httpClient: HttpClient) => (getStateOrSelectedServer: GetState | ServerWithId) => {
|
||||||
const { url: baseUrl, apiKey } = isGetState(getStateOrSelectedServer)
|
const { url: baseUrl, apiKey, forwardCredentials } = isGetState(getStateOrSelectedServer)
|
||||||
? getSelectedServerFromState(getStateOrSelectedServer)
|
? getSelectedServerFromState(getStateOrSelectedServer)
|
||||||
: getStateOrSelectedServer;
|
: getStateOrSelectedServer;
|
||||||
const serverKey = `${apiKey}_${baseUrl}`;
|
const serverKey = `${apiKey}_${baseUrl}_${forwardCredentials ? 'forward' : 'no-forward'}`;
|
||||||
|
const existingApiClient = apiClients.get(serverKey);
|
||||||
|
|
||||||
const apiClient = apiClients[serverKey] ?? new ShlinkApiClient(httpClient, { apiKey, baseUrl });
|
if (existingApiClient) {
|
||||||
apiClients[serverKey] = apiClient;
|
return existingApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClient = new ShlinkApiClient(
|
||||||
|
httpClient,
|
||||||
|
{ apiKey, baseUrl },
|
||||||
|
{ requestCredentials: forwardCredentials ? 'include' : undefined },
|
||||||
|
);
|
||||||
|
apiClients.set(serverKey, apiClient);
|
||||||
return apiClient;
|
return apiClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.app-container {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
padding-top: $headerHeight;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shlink-wrapper {
|
|
||||||
min-height: 100%;
|
|
||||||
padding-bottom: $footer-height + $footer-margin;
|
|
||||||
margin-bottom: -($footer-height + $footer-margin);
|
|
||||||
}
|
|
||||||
|
|
||||||
.shlink-footer {
|
|
||||||
height: $footer-height;
|
|
||||||
margin-top: $footer-margin;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,13 @@ import type { Settings } from '@shlinkio/shlink-web-component/settings';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
import { Route, Routes, useLocation } from 'react-router';
|
||||||
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
import { AppUpdateBanner } from '../common/AppUpdateBanner';
|
||||||
import { NotFound } from '../common/NotFound';
|
import { NotFound } from '../common/NotFound';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServersMap } from '../servers/data';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { forceUpdate } from '../utils/helpers/sw';
|
import { forceUpdate } from '../utils/helpers/sw';
|
||||||
import './App.scss';
|
|
||||||
|
|
||||||
type AppProps = {
|
type AppProps = {
|
||||||
fetchServers: () => void;
|
fetchServers: () => void;
|
||||||
@@ -62,28 +61,38 @@ const App: FCWithDeps<AppProps, AppDeps> = (
|
|||||||
}, [settings.ui?.theme]);
|
}, [settings.ui?.theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container-fluid app-container">
|
<div className="tw:px-3 tw:h-full">
|
||||||
<MainHeader />
|
<MainHeader />
|
||||||
|
|
||||||
<div className="app">
|
<div className="tw:h-full tw:pt-(--header-height)">
|
||||||
<div className={clsx('shlink-wrapper', { 'd-flex align-items-center pt-3': isHome })}>
|
<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>
|
<Routes>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/settings/*" element={<Settings />} />
|
<Route path="/settings">
|
||||||
|
{['', '*'].map((path) => <Route key={path} path={path} element={<Settings />} />)}
|
||||||
|
</Route>
|
||||||
<Route path="/manage-servers" element={<ManageServers />} />
|
<Route path="/manage-servers" element={<ManageServers />} />
|
||||||
<Route path="/server/create" element={<CreateServer />} />
|
<Route path="/server/create" element={<CreateServer />} />
|
||||||
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
<Route path="/server/:serverId/edit" element={<EditServer />} />
|
||||||
<Route path="/server/:serverId/*" element={<ShlinkWebComponentContainer />} />
|
<Route path="/server/:serverId">
|
||||||
|
{['', '*'].map((path) => <Route key={path} path={path} element={<ShlinkWebComponentContainer />} />)}
|
||||||
|
</Route>
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shlink-footer">
|
<div className="tw:h-(--footer-height) tw:mt-(--footer-margin) tw:md:px-4">
|
||||||
<ShlinkVersionsContainer />
|
<ShlinkVersionsContainer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppUpdateBanner isOpen={appUpdated} toggle={resetAppUpdate} forceUpdate={forceUpdate} />
|
<AppUpdateBanner isOpen={appUpdated} onClose={resetAppUpdate} forceUpdate={forceUpdate} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
@import '../utils/mixins/horizontal-align';
|
|
||||||
|
|
||||||
.app-update-banner.app-update-banner {
|
|
||||||
@include horizontal-align();
|
|
||||||
|
|
||||||
position: fixed;
|
|
||||||
top: $headerHeight - 25px;
|
|
||||||
padding: 0 4rem 0 0;
|
|
||||||
z-index: 1040;
|
|
||||||
margin: 0;
|
|
||||||
color: var(--text-color);
|
|
||||||
text-align: center;
|
|
||||||
width: 700px;
|
|
||||||
max-width: calc(100% - 30px);
|
|
||||||
box-shadow: 0 0 1rem var(--brand-color);
|
|
||||||
}
|
|
||||||
@@ -1,34 +1,47 @@
|
|||||||
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faSyncAlt as reloadIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { SimpleCard, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { MouseEventHandler } from 'react';
|
import { Button, Card, CloseButton } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import { forwardRef, useCallback } from 'react';
|
import { clsx } from 'clsx';
|
||||||
import { Alert, Button } from 'reactstrap';
|
import type { FC } from 'react';
|
||||||
import './AppUpdateBanner.scss';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
interface AppUpdateBannerProps {
|
interface AppUpdateBannerProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
toggle: MouseEventHandler<any>;
|
onClose: () => void;
|
||||||
forceUpdate: () => void;
|
forceUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppUpdateBanner = forwardRef<HTMLElement, AppUpdateBannerProps>(({ isOpen, toggle, forceUpdate }, ref) => {
|
export const AppUpdateBanner: FC<AppUpdateBannerProps> = ({ isOpen, onClose, forceUpdate }) => {
|
||||||
const [isUpdating,, setUpdating] = useToggle();
|
const [isUpdating,, setUpdating] = useToggle();
|
||||||
const update = useCallback(() => {
|
const update = useCallback(() => {
|
||||||
setUpdating();
|
setUpdating();
|
||||||
forceUpdate();
|
forceUpdate();
|
||||||
}, [forceUpdate, setUpdating]);
|
}, [forceUpdate, setUpdating]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert className="app-update-banner" isOpen={isOpen} toggle={toggle} tag={SimpleCard} color="secondary" innerRef={ref}>
|
<Card
|
||||||
<h4 className="mb-4">This app has just been updated!</h4>
|
role="alert"
|
||||||
<p className="mb-0">
|
className={clsx(
|
||||||
|
'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="tw:flex tw:items-center tw:justify-between">
|
||||||
|
<h5>This app has just been updated!</h5>
|
||||||
|
<CloseButton onClick={onClose} />
|
||||||
|
</Card.Header>
|
||||||
|
<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.
|
Restart it to enjoy the new features.
|
||||||
<Button role="button" disabled={isUpdating} className="ms-2" color="secondary" size="sm" onClick={update}>
|
<Button disabled={isUpdating} variant="secondary" solid onClick={update}>
|
||||||
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} className="ms-1" /></>}
|
{!isUpdating && <>Restart now <FontAwesomeIcon icon={reloadIcon} /></>}
|
||||||
{isUpdating && <>Restarting...</>}
|
{isUpdating && <>Restarting...</>}
|
||||||
</Button>
|
</Button>
|
||||||
</p>
|
</Card.Body>
|
||||||
</Alert>
|
</Card>
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
import { Component } from 'react';
|
import { Component } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
type ErrorHandlerProps = PropsWithChildren<{
|
type ErrorHandlerProps = PropsWithChildren<{
|
||||||
location?: typeof window.location;
|
location?: typeof window.location;
|
||||||
@@ -33,14 +33,11 @@ export class ErrorHandler extends Component<ErrorHandlerProps, ErrorHandlerState
|
|||||||
|
|
||||||
if (hasError) {
|
if (hasError) {
|
||||||
return (
|
return (
|
||||||
<div className="home">
|
<ErrorLayout title="Oops! This is awkward :S">
|
||||||
<SimpleCard className="p-4">
|
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
||||||
<h1>Oops! This is awkward :S</h1>
|
<br />
|
||||||
<p>It seems that something went wrong. Try refreshing the page or just click this button.</p>
|
<Button size="lg" onClick={() => location.reload()}>Take me back</Button>
|
||||||
<br />
|
</ErrorLayout>
|
||||||
<Button outline color="primary" onClick={() => location.reload()}>Take me back</Button>
|
|
||||||
</SimpleCard>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/common/ErrorLayout.tsx
Normal file
15
src/common/ErrorLayout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { SimpleCard } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export type ErrorLayoutProps = PropsWithChildren<{
|
||||||
|
title: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export const ErrorLayout: FC<ErrorLayoutProps> = ({ children, title }) => (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.home__title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
font-size: 2.2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.home__servers-container {
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
border-left: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faExternalLinkAlt, faPlus } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Button, Card } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { ExternalLink } from 'react-external-link';
|
import { ExternalLink } from 'react-external-link';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { Card } from 'reactstrap';
|
|
||||||
import type { ServersMap } from '../servers/data';
|
import type { ServersMap } from '../servers/data';
|
||||||
import { ServersListGroup } from '../servers/ServersListGroup';
|
import { ServersListGroup } from '../servers/ServersListGroup';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './Home.scss';
|
|
||||||
|
|
||||||
interface HomeProps {
|
export type HomeProps = {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const Home = ({ servers }: HomeProps) => {
|
export const Home = ({ servers }: HomeProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,42 +27,42 @@ export const Home = ({ servers }: HomeProps) => {
|
|||||||
}, [serversList, navigate]);
|
}, [serversList, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-100">
|
<div className="tw:w-full">
|
||||||
<Card className="mx-auto" style={{ maxWidth: '720px' }}>
|
<Card className="tw:mx-auto tw:max-w-[720px] tw:overflow-hidden">
|
||||||
<div className="d-flex flex-column flex-md-row">
|
<div className="tw:flex tw:flex-col tw:md:flex-row">
|
||||||
<div className="p-4 d-none d-md-flex align-items-center" style={{ width: '40%' }}>
|
<div className="tw:p-6 tw:hidden tw:md:flex tw:items-center tw:w-[40%]">
|
||||||
<div className="w-100">
|
<div className="tw:w-full">
|
||||||
<ShlinkLogo />
|
<ShlinkLogo />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="home__servers-container flex-grow-1">
|
<div className="tw:md:border-l tw:border-lm-border tw:dark:border-dm-border tw:flex-grow">
|
||||||
<h1
|
<h1
|
||||||
className={clsx('home__title p-4 text-center m-0', { 'border-bottom': !hasServers })}
|
className={clsx(
|
||||||
style={{ borderColor: 'var(--border-color) !important' }}
|
'tw:p-4 tw:text-center tw:border-lm-border tw:dark:border-dm-border',
|
||||||
|
{ 'tw:border-b': !hasServers },
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Welcome!
|
Welcome!
|
||||||
</h1>
|
</h1>
|
||||||
<ServersListGroup embedded servers={serversList}>
|
{hasServers ? <ServersListGroup servers={serversList} /> : (
|
||||||
{!hasServers && (
|
<div className="tw:p-6 tw:text-center tw:flex tw:flex-col tw:gap-12 tw:text-xl">
|
||||||
<div className="p-4 text-center d-flex flex-column gap-5">
|
<p>This application will help you manage your Shlink servers.</p>
|
||||||
<p className="mb-0">This application will help you manage your Shlink servers.</p>
|
<p>
|
||||||
<p className="mb-0">
|
<Button to="/server/create" size="lg" inline>
|
||||||
<Link to="/server/create" className="btn btn-outline-primary btn-lg me-2">
|
<FontAwesomeIcon icon={faPlus} /> Add a server
|
||||||
<FontAwesomeIcon icon={faPlus}/> <span className="ms-1">Add a server</span>
|
</Button>
|
||||||
</Link>
|
</p>
|
||||||
</p>
|
<p>
|
||||||
<p className="mb-0">
|
<ExternalLink href="https://shlink.io/documentation">
|
||||||
<ExternalLink href="https://shlink.io/documentation">
|
<small>
|
||||||
<small>
|
<span className="tw:mr-2">Learn more about Shlink</span>
|
||||||
<span className="me-2">Learn more about Shlink</span>
|
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||||
<FontAwesomeIcon icon={faExternalLinkAlt}/>
|
</small>
|
||||||
</small>
|
</ExternalLink>
|
||||||
</ExternalLink>
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</ServersListGroup>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.main-header.main-header {
|
|
||||||
color: white;
|
|
||||||
background-color: var(--brand-color) !important;
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
color: inherit !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-header__brand-logo {
|
|
||||||
width: 26px;
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-header__toggle-icon {
|
|
||||||
width: 20px;
|
|
||||||
transition: transform 300ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-header__toggle-icon--opened {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
@@ -4,12 +4,11 @@ import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router';
|
||||||
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import { ShlinkLogo } from './img/ShlinkLogo';
|
import { ShlinkLogo } from './img/ShlinkLogo';
|
||||||
import './MainHeader.scss';
|
|
||||||
|
|
||||||
type MainHeaderDeps = {
|
type MainHeaderDeps = {
|
||||||
ServersDropdown: FC;
|
ServersDropdown: FC;
|
||||||
@@ -25,20 +24,22 @@ const MainHeader: FCWithDeps<unknown, MainHeaderDeps> = () => {
|
|||||||
useEffect(collapse, [location, collapse]);
|
useEffect(collapse, [location, collapse]);
|
||||||
|
|
||||||
const settingsPath = '/settings';
|
const settingsPath = '/settings';
|
||||||
const toggleClass = clsx('main-header__toggle-icon', { 'main-header__toggle-icon--opened': isNotCollapsed });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Navbar color="primary" dark fixed="top" className="main-header" expand="md">
|
<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="/">
|
<NavbarBrand tag={Link} to="/">
|
||||||
<ShlinkLogo className="main-header__brand-logo" color="white" /> Shlink
|
<ShlinkLogo className="tw:inline tw:w-7 tw:mr-1" color="white" /> Shlink
|
||||||
</NavbarBrand>
|
</NavbarBrand>
|
||||||
|
|
||||||
<NavbarToggler onClick={toggleCollapse}>
|
<NavbarToggler onClick={toggleCollapse}>
|
||||||
<FontAwesomeIcon icon={arrowIcon} className={toggleClass} />
|
<FontAwesomeIcon
|
||||||
|
icon={arrowIcon}
|
||||||
|
className={clsx('tw:transition-transform tw:duration-300', { 'tw:rotate-180': isNotCollapsed })}
|
||||||
|
/>
|
||||||
</NavbarToggler>
|
</NavbarToggler>
|
||||||
|
|
||||||
<Collapse navbar isOpen={isNotCollapsed}>
|
<Collapse navbar isOpen={isNotCollapsed}>
|
||||||
<Nav navbar className="ms-auto">
|
<Nav navbar className="tw:ml-auto">
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
<NavLink tag={Link} to={settingsPath} active={pathname.startsWith(settingsPath)}>
|
||||||
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
<FontAwesomeIcon icon={cogsIcon} /> Settings
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.no-menu-wrapper {
|
|
||||||
padding: 15px 0 0;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
padding: 30px 20px 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import './NoMenuLayout.scss';
|
|
||||||
|
|
||||||
export type NoMenuLayoutProps = PropsWithChildren & {
|
export type NoMenuLayoutProps = PropsWithChildren & {
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
export const NoMenuLayout: FC<NoMenuLayoutProps> = ({ children, className }) => (
|
||||||
<div className={clsx('no-menu-wrapper container-xl', className)}>{children}</div>
|
<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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { ErrorLayout } from './ErrorLayout';
|
||||||
|
|
||||||
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
type NotFoundProps = PropsWithChildren<{ to?: string }>;
|
||||||
|
|
||||||
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
export const NotFound: FC<NotFoundProps> = ({ to = '/', children = 'Home' }) => (
|
||||||
<div className="home">
|
<ErrorLayout title="Oops! We could not find requested route.">
|
||||||
<SimpleCard className="p-4">
|
<p>
|
||||||
<h2>Oops! We could not find requested route.</h2>
|
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
||||||
<p>
|
button.
|
||||||
Use your browser's back button to navigate to the page you have previously come from, or just press this
|
</p>
|
||||||
button.
|
<br />
|
||||||
</p>
|
<Button inline to={to} size="lg">{children}</Button>
|
||||||
<br />
|
</ErrorLayout>
|
||||||
<Link to={to} className="btn btn-outline-primary btn-lg">{children}</Link>
|
|
||||||
</SimpleCard>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router';
|
||||||
|
|
||||||
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
|
export const ScrollToTop: FC<PropsWithChildren> = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface ShlinkVersionsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
const VersionLink = ({ project, version }: { project: 'shlink' | 'shlink-web-client'; version: string }) => (
|
||||||
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="text-muted">
|
<ExternalLink href={`https://github.com/shlinkio/${project}/releases/${version}`} className="tw:text-gray-500">
|
||||||
<b>{version}</b>
|
<b>{version}</b>
|
||||||
</ExternalLink>
|
</ExternalLink>
|
||||||
);
|
);
|
||||||
@@ -21,7 +21,7 @@ export const ShlinkVersions = ({ selectedServer, clientVersion = SHLINK_WEB_CLIE
|
|||||||
const normalizedClientVersion = normalizeVersion(clientVersion);
|
const normalizedClientVersion = normalizeVersion(clientVersion);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<small className="text-muted">
|
<small className="tw:text-gray-500">
|
||||||
{isReachableServer(selectedServer) && (
|
{isReachableServer(selectedServer) && (
|
||||||
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
<>Server: <VersionLink project="shlink" version={selectedServer.printableVersion} /> - </>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.shlink-versions-container--with-sidebar {
|
|
||||||
margin-left: 0;
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
margin-left: $asideMenuWidth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import { clsx } from 'clsx';
|
|||||||
import type { SelectedServer } from '../servers/data';
|
import type { SelectedServer } from '../servers/data';
|
||||||
import { isReachableServer } from '../servers/data';
|
import { isReachableServer } from '../servers/data';
|
||||||
import { ShlinkVersions } from './ShlinkVersions';
|
import { ShlinkVersions } from './ShlinkVersions';
|
||||||
import './ShlinkVersionsContainer.scss';
|
|
||||||
|
|
||||||
export type ShlinkVersionsContainerProps = {
|
export type ShlinkVersionsContainerProps = {
|
||||||
selectedServer: SelectedServer;
|
selectedServer: SelectedServer;
|
||||||
@@ -10,9 +9,7 @@ export type ShlinkVersionsContainerProps = {
|
|||||||
|
|
||||||
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
|
export const ShlinkVersionsContainer = ({ selectedServer }: ShlinkVersionsContainerProps) => (
|
||||||
<div
|
<div
|
||||||
className={clsx('text-center', {
|
className={clsx('tw:text-center', { 'tw:md:ml-(--aside-menu-width)': isReachableServer(selectedServer) })}
|
||||||
'shlink-versions-container--with-sidebar': isReachableServer(selectedServer),
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<ShlinkVersions selectedServer={selectedServer} />
|
<ShlinkVersions selectedServer={selectedServer} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/browser';
|
import { FetchHttpClient } from '@shlinkio/shlink-js-sdk/fetch';
|
||||||
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
|
import { ShlinkWebComponent } from '@shlinkio/shlink-web-component';
|
||||||
import type Bottle from 'bottlejs';
|
import type Bottle from 'bottlejs';
|
||||||
import type { ConnectDecorator } from '../../container/types';
|
import type { ConnectDecorator } from '../../container/types';
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { IContainer } from 'bottlejs';
|
import type { IContainer } from 'bottlejs';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useRef } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
export type FCWithDeps<Props, Deps> = FC<Props> & Partial<Deps>;
|
||||||
|
|
||||||
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
export function useDependencies<Deps>(obj: Deps): Omit<Required<Deps>, keyof FC> {
|
||||||
const depsRef = useRef(obj as Omit<Required<Deps>, keyof FC>);
|
return useMemo(() => obj as Omit<Required<Deps>, keyof FC>, [obj]);
|
||||||
return depsRef.current;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
export function componentFactory<Deps, CompType = Omit<Partial<Deps>, keyof FC>>(
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet. Includes SASS var overrides
|
@use '../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; // Before bootstrap stylesheet
|
||||||
@import '../node_modules/bootstrap/scss/bootstrap.scss';
|
@use '../node_modules/bootstrap/scss/bootstrap.scss' with (
|
||||||
@import '../node_modules/@shlinkio/shlink-frontend-kit/dist/index'; // After bootstrap. Includes CSS overwrites
|
$primary: base.$mainColor // Override bootstrap's primary color
|
||||||
@import '../node_modules/@shlinkio/shlink-web-component/dist/index';
|
);
|
||||||
|
@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;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router';
|
||||||
import pack from '../package.json';
|
import pack from '../package.json';
|
||||||
import { container } from './container';
|
import { container } from './container';
|
||||||
import { setUpStore } from './container/store';
|
import { setUpStore } from './container/store';
|
||||||
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
import { register as registerServiceWorker } from './serviceWorkerRegistration';
|
||||||
|
import './tailwind.css';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
const store = setUpStore(container);
|
const store = setUpStore(container);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Result, useToggle } 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 type { FC } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
import { Button } from 'reactstrap';
|
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
@@ -26,11 +27,11 @@ type CreateServerDeps = {
|
|||||||
useTimeoutToggle: TimeoutToggle;
|
useTimeoutToggle: TimeoutToggle;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ImportResult = ({ type }: { type: 'error' | 'success' }) => (
|
const ImportResult = ({ variant }: Pick<ResultProps, 'variant'>) => (
|
||||||
<div className="mt-3">
|
<div className="tw:mt-4">
|
||||||
<Result type={type}>
|
<Result variant={variant}>
|
||||||
{type === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
{variant === 'success' && 'Servers properly imported. You can now select one from the list :)'}
|
||||||
{type === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
{variant === 'error' && 'The servers could not be imported. Make sure the format is correct.'}
|
||||||
</Result>
|
</Result>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -40,7 +41,9 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
const hasServers = !!Object.keys(servers).length;
|
const hasServers = !!Object.keys(servers).length;
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
const [serversImported, setServersImported] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
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 [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
const [isConfirmModalOpen, toggleConfirmModal] = useToggle();
|
||||||
const [serverData, setServerData] = useState<ServerData>();
|
const [serverData, setServerData] = useState<ServerData>();
|
||||||
@@ -66,22 +69,22 @@ const CreateServer: FCWithDeps<CreateServerProps, CreateServerDeps> = ({ servers
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm title={<h5 className="mb-0">Add new server</h5>} onSubmit={onSubmit}>
|
<ServerForm title="Add new server" onSubmit={onSubmit}>
|
||||||
{!hasServers && (
|
{!hasServers && (
|
||||||
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onImportError={setErrorImporting} />
|
<ImportServersBtn tooltipPlacement="top" onImport={setServersImported} onError={setErrorImporting} />
|
||||||
)}
|
)}
|
||||||
{hasServers && <Button outline onClick={goBack}>Cancel</Button>}
|
{hasServers && <Button variant="secondary" onClick={goBack}>Cancel</Button>}
|
||||||
<Button outline color="primary" className="ms-2">Create server</Button>
|
<Button type="submit">Create server</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
|
|
||||||
{serversImported && <ImportResult type="success" />}
|
{serversImported && <ImportResult variant="success" />}
|
||||||
{errorImporting && <ImportResult type="error" />}
|
{errorImporting && <ImportResult variant="error" />}
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isConfirmModalOpen}
|
open={isConfirmModalOpen}
|
||||||
duplicatedServers={serverData ? [serverData] : []}
|
duplicatedServers={serverData ? [serverData] : []}
|
||||||
onDiscard={goBack}
|
onClose={goBack}
|
||||||
onSave={() => serverData && saveNewServer(serverData)}
|
onConfirm={() => serverData && saveNewServer(serverData)}
|
||||||
/>
|
/>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { faMinusCircle as deleteIcon } from '@fortawesome/free-solid-svg-icons';
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { clsx } from 'clsx';
|
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
@@ -10,28 +9,29 @@ import type { DeleteServerModalProps } from './DeleteServerModal';
|
|||||||
|
|
||||||
export type DeleteServerButtonProps = PropsWithChildren<{
|
export type DeleteServerButtonProps = PropsWithChildren<{
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
className?: string;
|
|
||||||
textClassName?: string;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
type DeleteServerButtonDeps = {
|
type DeleteServerButtonDeps = {
|
||||||
DeleteServerModal: FC<DeleteServerModalProps>;
|
DeleteServerModal: FC<DeleteServerModalProps>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = (
|
const DeleteServerButton: FCWithDeps<DeleteServerButtonProps, DeleteServerButtonDeps> = ({ server, children }) => {
|
||||||
{ server, className, children, textClassName },
|
|
||||||
) => {
|
|
||||||
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
const { DeleteServerModal } = useDependencies(DeleteServerButton);
|
||||||
const [isModalOpen, , showModal, hideModal] = useToggle();
|
const [isModalOpen, , showModal, hideModal] = useToggle();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const onClose = useCallback((confirmed: boolean) => {
|
||||||
|
hideModal();
|
||||||
|
if (confirmed) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [hideModal, navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" className={clsx(className, 'p-0 bg-transparent border-0')} onClick={showModal}>
|
<button type="button" className="tw:text-danger tw:hover:underline" onClick={showModal}>
|
||||||
{!children && <FontAwesomeIcon fixedWidth icon={deleteIcon} />}
|
{children}
|
||||||
<span className={textClassName}>{children ?? 'Remove this server'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<DeleteServerModal server={server} open={isModalOpen} onClose={onClose} />
|
||||||
<DeleteServerModal server={server} isOpen={isModalOpen} toggle={hideModal} />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,44 +1,37 @@
|
|||||||
|
import type { ExitAction } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
|
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useRef } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
|
|
||||||
export interface DeleteServerModalProps {
|
export type DeleteServerModalProps = {
|
||||||
server: ServerWithId;
|
server: ServerWithId;
|
||||||
toggle: () => void;
|
onClose: (confirmed: boolean) => void;
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
redirectHome?: boolean;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
interface DeleteServerModalConnectProps extends DeleteServerModalProps {
|
type DeleteServerModalConnectProps = DeleteServerModalProps & {
|
||||||
deleteServer: (server: ServerWithId) => void;
|
deleteServer: (server: ServerWithId) => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
export const DeleteServerModal: FC<DeleteServerModalConnectProps> = ({ server, onClose, open, deleteServer }) => {
|
||||||
{ server, toggle, isOpen, deleteServer, redirectHome = true },
|
const onClosed = useCallback((exitAction: ExitAction) => {
|
||||||
) => {
|
if (exitAction === 'confirm') {
|
||||||
const navigate = useNavigate();
|
deleteServer(server);
|
||||||
const doDelete = useRef<boolean>(false);
|
|
||||||
const toggleAndDelete = () => {
|
|
||||||
doDelete.current = true;
|
|
||||||
toggle();
|
|
||||||
};
|
|
||||||
const onClosed = () => {
|
|
||||||
if (!doDelete.current) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}, [deleteServer, server]);
|
||||||
deleteServer(server);
|
|
||||||
if (redirectHome) {
|
|
||||||
navigate('/');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} toggle={toggle} centered onClosed={onClosed}>
|
<CardModal
|
||||||
<ModalHeader toggle={toggle} className="text-danger">Remove server</ModalHeader>
|
open={open}
|
||||||
<ModalBody>
|
title="Remove server"
|
||||||
|
variant="danger"
|
||||||
|
onClose={() => onClose(false)}
|
||||||
|
onConfirm={() => onClose(true)}
|
||||||
|
onClosed={onClosed}
|
||||||
|
confirmText="Delete"
|
||||||
|
>
|
||||||
|
<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>Are you sure you want to remove <b>{server ? server.name : ''}</b>?</p>
|
||||||
<p>
|
<p>
|
||||||
<i>
|
<i>
|
||||||
@@ -46,11 +39,7 @@ export const DeleteServerModal: FC<DeleteServerModalConnectProps> = (
|
|||||||
You can create it again at any moment.
|
You can create it again at any moment.
|
||||||
</i>
|
</i>
|
||||||
</p>
|
</p>
|
||||||
</ModalBody>
|
</div>
|
||||||
<ModalFooter>
|
</CardModal>
|
||||||
<Button color="link" onClick={toggle}>Cancel</Button>
|
|
||||||
<Button color="danger" onClick={toggleAndDelete}>Delete</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { 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 type { FC } from 'react';
|
||||||
import { Button } from 'reactstrap';
|
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory } from '../container/utils';
|
import { componentFactory } from '../container/utils';
|
||||||
@@ -40,12 +40,12 @@ const EditServer: FCWithDeps<EditServerProps, EditServerDeps> = withSelectedServ
|
|||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<ServerForm
|
<ServerForm
|
||||||
title={<h5 className="mb-0">Edit "{selectedServer.name}"</h5>}
|
title={<>Edit "{selectedServer.name}"</>}
|
||||||
initialValues={selectedServer}
|
initialValues={selectedServer}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
>
|
>
|
||||||
<Button outline className="me-2" onClick={goBack}>Cancel</Button>
|
<Button variant="secondary" onClick={goBack}>Cancel</Button>
|
||||||
<Button outline color="primary">Save</Button>
|
<Button type="submit">Save</Button>
|
||||||
</ServerForm>
|
</ServerForm>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileDownload as exportIcon, faPlus as plusIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
import type { TimeoutToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import { Result, SearchField, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
import { Button, Result, SearchInput, SimpleCard, Table } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Button } from 'reactstrap';
|
|
||||||
import { NoMenuLayout } from '../common/NoMenuLayout';
|
import { NoMenuLayout } from '../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
@@ -41,48 +39,50 @@ const ManageServers: FCWithDeps<ManageServersProps, ManageServersDeps> = ({ serv
|
|||||||
[allServers, searchTerm],
|
[allServers, searchTerm],
|
||||||
);
|
);
|
||||||
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
const hasAutoConnect = allServers.some(({ autoConnect }) => !!autoConnect);
|
||||||
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
const [errorImporting, setErrorImporting] = useTimeoutToggle(false, SHOW_IMPORT_MSG_TIME);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout className="d-flex flex-column gap-3">
|
<NoMenuLayout className="tw:flex tw:flex-col tw:gap-y-4">
|
||||||
<SearchField onChange={setSearchTerm} />
|
<SearchInput onChange={setSearchTerm} />
|
||||||
|
|
||||||
<div className="d-flex flex-column flex-md-row gap-2">
|
<div className="tw:flex tw:flex-col tw:md:flex-row tw:gap-2">
|
||||||
<div className="d-flex gap-2">
|
<div className="tw:flex tw:gap-2">
|
||||||
<ImportServersBtn className="flex-fill" onImportError={setErrorImporting}>Import servers</ImportServersBtn>
|
<ImportServersBtn className="tw:flex-grow" onError={setErrorImporting}>Import servers</ImportServersBtn>
|
||||||
{filteredServers.length > 0 && (
|
{filteredServers.length > 0 && (
|
||||||
<Button outline className="flex-fill" onClick={async () => serversExporter.exportServers()}>
|
<Button variant="secondary" className="tw:flex-grow" onClick={async () => serversExporter.exportServers()}>
|
||||||
<FontAwesomeIcon icon={exportIcon} fixedWidth /> Export servers
|
<FontAwesomeIcon icon={exportIcon} /> Export servers
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button outline color="primary" className="ms-md-auto" tag={Link} to="/server/create">
|
<Button className="tw:md:ml-auto" to="/server/create">
|
||||||
<FontAwesomeIcon icon={plusIcon} fixedWidth /> Add a server
|
<FontAwesomeIcon icon={plusIcon} /> Add a server
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleCard>
|
<SimpleCard className="card">
|
||||||
<table className="table table-hover responsive-table mb-0">
|
<Table header={(
|
||||||
<thead className="responsive-table__header">
|
<Table.Row>
|
||||||
<tr>
|
{hasAutoConnect && (
|
||||||
{hasAutoConnect && <th style={{ width: '50px' }}><span className="sr-only">Auto-connect</span></th>}
|
<Table.Cell className="tw:w-[35px]"><span className="tw:sr-only">Auto-connect</span></Table.Cell>
|
||||||
<th>Name</th>
|
)}
|
||||||
<th>Base URL</th>
|
<Table.Cell>Name</Table.Cell>
|
||||||
<th><span className="sr-only">Options</span></th>
|
<Table.Cell>Base URL</Table.Cell>
|
||||||
</tr>
|
<Table.Cell><span className="sr-only">Options</span></Table.Cell>
|
||||||
</thead>
|
</Table.Row>
|
||||||
<tbody>
|
)}>
|
||||||
{!filteredServers.length && <tr className="text-center"><td colSpan={4}>No servers found.</td></tr>}
|
{!filteredServers.length && (
|
||||||
{filteredServers.map((server) => (
|
<Table.Row className="tw:text-center"><Table.Cell colSpan={4}>No servers found.</Table.Cell></Table.Row>
|
||||||
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
)}
|
||||||
))}
|
{filteredServers.map((server) => (
|
||||||
</tbody>
|
<ManageServersRow key={server.id} server={server} hasAutoConnect={hasAutoConnect} />
|
||||||
</table>
|
))}
|
||||||
|
</Table>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
{errorImporting && (
|
{errorImporting && (
|
||||||
<div>
|
<div>
|
||||||
<Result type="error">The servers could not be imported. Make sure the format is correct.</Result>
|
<Result variant="error">The servers could not be imported. Make sure the format is correct.</Result>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faCheck as checkIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
@@ -21,27 +22,27 @@ const ManageServersRow: FCWithDeps<ManageServersRowProps, ManageServersRowDeps>
|
|||||||
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
const { ManageServersRowDropdown } = useDependencies(ManageServersRow);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="responsive-table__row">
|
<Table.Row className="tw:relative">
|
||||||
{hasAutoConnect && (
|
{hasAutoConnect && (
|
||||||
<td className="responsive-table__cell" data-th="Auto-connect">
|
<Table.Cell columnName="Auto-connect">
|
||||||
{server.autoConnect && (
|
{server.autoConnect && (
|
||||||
<>
|
<>
|
||||||
<FontAwesomeIcon icon={checkIcon} className="text-primary" id="autoConnectIcon" />
|
<FontAwesomeIcon icon={checkIcon} className="tw:text-brand" id="autoConnectIcon" />
|
||||||
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
<UncontrolledTooltip target="autoConnectIcon" placement="right">
|
||||||
Auto-connect to this server
|
Auto-connect to this server
|
||||||
</UncontrolledTooltip>
|
</UncontrolledTooltip>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</td>
|
</Table.Cell>
|
||||||
)}
|
)}
|
||||||
<th className="responsive-table__cell" data-th="Name">
|
<Table.Cell className="tw:font-bold" columnName="Name">
|
||||||
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
<Link to={`/server/${server.id}`}>{server.name}</Link>
|
||||||
</th>
|
</Table.Cell>
|
||||||
<td className="responsive-table__cell" data-th="Base URL">{server.url}</td>
|
<Table.Cell columnName="Base URL" className="tw:max-lg:border-b-0">{server.url}</Table.Cell>
|
||||||
<td className="responsive-table__cell text-end">
|
<Table.Cell className="tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0">
|
||||||
<ManageServersRowDropdown server={server} />
|
<ManageServersRowDropdown server={server} />
|
||||||
</td>
|
</Table.Cell>
|
||||||
</tr>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { RowDropdownBtn, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import type { FCWithDeps } from '../container/utils';
|
import type { FCWithDeps } from '../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../container/utils';
|
import { componentFactory, useDependencies } from '../container/utils';
|
||||||
@@ -37,23 +37,25 @@ const ManageServersRowDropdown: FCWithDeps<ManageServersRowDropdownConnectProps,
|
|||||||
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
const autoConnectIcon = isAutoConnect ? toggleOffIcon : toggleOnIcon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
|
<>
|
||||||
<DropdownItem tag={Link} to={serverUrl}>
|
<RowDropdownBtn minWidth={isAutoConnect ? 210 : 170}>
|
||||||
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
<DropdownItem tag={Link} to={serverUrl}>
|
||||||
</DropdownItem>
|
<FontAwesomeIcon icon={connectIcon} fixedWidth /> Connect
|
||||||
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
</DropdownItem>
|
||||||
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
<DropdownItem tag={Link} to={`${serverUrl}/edit`}>
|
||||||
</DropdownItem>
|
<FontAwesomeIcon icon={editIcon} fixedWidth /> Edit server
|
||||||
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
</DropdownItem>
|
||||||
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
<DropdownItem onClick={() => setAutoConnect(server, !isAutoConnect)}>
|
||||||
</DropdownItem>
|
<FontAwesomeIcon icon={autoConnectIcon} fixedWidth /> {isAutoConnect ? 'Do not a' : 'A'}uto-connect
|
||||||
<DropdownItem divider tag="hr" />
|
</DropdownItem>
|
||||||
<DropdownItem className="dropdown-item--danger" onClick={showModal}>
|
<DropdownItem divider tag="hr" />
|
||||||
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
<DropdownItem className="tw:text-danger" onClick={showModal}>
|
||||||
</DropdownItem>
|
<FontAwesomeIcon icon={deleteIcon} fixedWidth /> Remove server
|
||||||
|
</DropdownItem>
|
||||||
|
</RowDropdownBtn>
|
||||||
|
|
||||||
<DeleteServerModal redirectHome={false} server={server} isOpen={isModalOpen} toggle={hideModal} />
|
<DeleteServerModal server={server} open={isModalOpen} onClose={hideModal} />
|
||||||
</RowDropdownBtn>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus as plusIcon, faServer as serverIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
|
||||||
import type { SelectedServer, ServersMap } from './data';
|
import type { SelectedServer, ServersMap } from './data';
|
||||||
import { getServerId } from './data';
|
import { getServerId } from './data';
|
||||||
@@ -13,36 +13,30 @@ export interface ServersDropdownProps {
|
|||||||
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
export const ServersDropdown = ({ servers, selectedServer }: ServersDropdownProps) => {
|
||||||
const serversList = Object.values(servers);
|
const serversList = Object.values(servers);
|
||||||
|
|
||||||
const renderServers = () => {
|
|
||||||
if (serversList.length === 0) {
|
|
||||||
return (
|
|
||||||
<DropdownItem tag={Link} to="/server/create">
|
|
||||||
<FontAwesomeIcon icon={plusIcon} /> <span className="ms-1">Add a server</span>
|
|
||||||
</DropdownItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{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="ms-1">Manage servers</span>
|
|
||||||
</DropdownItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UncontrolledDropdown nav inNavbar>
|
<UncontrolledDropdown nav inNavbar>
|
||||||
<DropdownToggle nav caret>
|
<DropdownToggle nav caret>
|
||||||
<FontAwesomeIcon icon={serverIcon} /> <span className="ms-1">Servers</span>
|
<FontAwesomeIcon icon={serverIcon} /> <span className="tw:ml-1">Servers</span>
|
||||||
</DropdownToggle>
|
</DropdownToggle>
|
||||||
<DropdownMenu end style={{ right: 0 }}>{renderServers()}</DropdownMenu>
|
<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>
|
</UncontrolledDropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
@import '../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
@import '../utils/mixins/vertical-align';
|
|
||||||
@import '../utils/mixins/thin-scroll';
|
|
||||||
|
|
||||||
.servers-list__list-group.servers-list__list-group {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__list-group:not(.servers-list__list-group--embedded) {
|
|
||||||
max-width: 400px;
|
|
||||||
box-shadow: 0 .125rem .25rem rgb(0 0 0 / .075);
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__server-item.servers-list__server-item {
|
|
||||||
text-align: left;
|
|
||||||
position: relative;
|
|
||||||
padding: .75rem 2.5rem .75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__server-item:not(:hover) {
|
|
||||||
color: $mainColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__server-item:hover {
|
|
||||||
background-color: var(--secondary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__server-item-icon {
|
|
||||||
@include vertical-align();
|
|
||||||
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__list-group--embedded.servers-list__list-group--embedded {
|
|
||||||
border-radius: 0;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
|
|
||||||
@media (min-width: $mdMin) {
|
|
||||||
max-height: 220px;
|
|
||||||
overflow-x: auto;
|
|
||||||
|
|
||||||
@include thin-scroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
.servers-list__server-item {
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,43 @@
|
|||||||
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faChevronRight as chevronIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { ListGroup, ListGroupItem } from 'reactstrap';
|
|
||||||
import type { ServerWithId } from './data';
|
import type { ServerWithId } from './data';
|
||||||
import './ServersListGroup.scss';
|
|
||||||
|
|
||||||
type ServersListGroupProps = PropsWithChildren<{
|
type ServersListGroupProps = {
|
||||||
servers: ServerWithId[];
|
servers: ServerWithId[];
|
||||||
embedded?: boolean;
|
borderless?: boolean;
|
||||||
}>;
|
};
|
||||||
|
|
||||||
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
const ServerListItem = ({ id, name }: { id: string; name: string }) => (
|
||||||
<ListGroupItem tag={Link} to={`/server/${id}`} className="servers-list__server-item">
|
<Link
|
||||||
{name}
|
to={`/server/${id}`}
|
||||||
<FontAwesomeIcon icon={chevronIcon} className="servers-list__server-item-icon" />
|
className={clsx(
|
||||||
</ListGroupItem>
|
'servers-list__server-item',
|
||||||
|
'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="tw:truncate">{name}</span>
|
||||||
|
<FontAwesomeIcon icon={chevronIcon} />
|
||||||
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, children, embedded = false }) => (
|
export const ServersListGroup: FC<ServersListGroupProps> = ({ servers, borderless }) => (
|
||||||
<>
|
<>
|
||||||
{children && <div data-testid="title" className="mb-0 fs-5 fw-normal lh-sm">{children}</div>}
|
|
||||||
{servers.length > 0 && (
|
{servers.length > 0 && (
|
||||||
<ListGroup
|
<div
|
||||||
data-testid="list"
|
data-testid="list"
|
||||||
tag="div"
|
className={clsx(
|
||||||
className={clsx('servers-list__list-group', { 'servers-list__list-group--embedded': embedded })}
|
'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} />)}
|
{servers.map(({ id, name }) => <ServerListItem key={id} id={id} name={name} />)}
|
||||||
</ListGroup>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface ServerData {
|
|||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
|
forwardCredentials?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerWithId extends ServerData {
|
export interface ServerWithId extends ServerData {
|
||||||
@@ -44,4 +45,31 @@ export const isNotFoundServer = (server: SelectedServer): server is NotFoundServ
|
|||||||
|
|
||||||
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
export const getServerId = (server: SelectedServer) => (isServerWithId(server) ? server.id : '');
|
||||||
|
|
||||||
export const serverWithIdToServerData = ({ name, url, apiKey }: ServerWithId): ServerData => ({ name, url, apiKey });
|
/**
|
||||||
|
* Expose values that represent provided server, in a way that can be serialized in JSON or CSV strings.
|
||||||
|
*/
|
||||||
|
export const serializeServer = ({ name, url, apiKey, forwardCredentials }: ServerData): Record<string, string> => ({
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
apiKey,
|
||||||
|
forwardCredentials: forwardCredentials ? 'true' : 'false',
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateServerData = (server: any): server is ServerData =>
|
||||||
|
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provided a record, it picks the right properties to build a ServerData object.
|
||||||
|
* @throws Error If any of the required ServerData properties is missing.
|
||||||
|
*/
|
||||||
|
export const deserializeServer = (potentialServer: Record<string, unknown>): ServerData => {
|
||||||
|
const { forwardCredentials, ...serverData } = potentialServer;
|
||||||
|
if (!validateServerData(serverData)) {
|
||||||
|
throw new Error('Server is missing required "url", "apiKey" and/or "name" properties');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...serverData,
|
||||||
|
forwardCredentials: forwardCredentials === 'true',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
|
import { CardModal } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Fragment } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
|
|
||||||
import type { ServerData } from '../data';
|
import type { ServerData } from '../data';
|
||||||
|
|
||||||
interface DuplicatedServersModalProps {
|
export type DuplicatedServersModalProps = {
|
||||||
duplicatedServers: ServerData[];
|
duplicatedServers: ServerData[];
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
onDiscard: () => void;
|
onClose: () => void;
|
||||||
onSave: () => void;
|
onConfirm: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
export const DuplicatedServersModal: FC<DuplicatedServersModalProps> = (
|
||||||
{ isOpen, duplicatedServers, onDiscard, onSave },
|
{ open, duplicatedServers, onClose, onConfirm },
|
||||||
) => {
|
) => {
|
||||||
const hasMultipleServers = duplicatedServers.length > 1;
|
const hasMultipleServers = duplicatedServers.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal centered isOpen={isOpen}>
|
<CardModal
|
||||||
<ModalHeader>Duplicated server{hasMultipleServers && 's'}</ModalHeader>
|
size="lg"
|
||||||
<ModalBody>
|
title={`Duplicated server${hasMultipleServers ? 's' : ''}`}
|
||||||
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
open={open}
|
||||||
<ul>
|
onClose={onClose}
|
||||||
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
onConfirm={onConfirm}
|
||||||
<Fragment key={index}>
|
confirmText={`Save duplicate${hasMultipleServers ? 's' : ''}`}
|
||||||
<li>URL: <b>{url}</b></li>
|
cancelText={hasMultipleServers ? 'Ignore duplicates' : 'Discard'}
|
||||||
<li>API key: <b>{apiKey}</b></li>
|
>
|
||||||
</Fragment>
|
<p>{hasMultipleServers ? 'The next servers already exist:' : 'There is already a server with:'}</p>
|
||||||
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
<ul className="tw:list-disc tw:mt-4">
|
||||||
</ul>
|
{duplicatedServers.map(({ url, apiKey }, index) => (!hasMultipleServers ? (
|
||||||
<span>
|
<Fragment key={index}>
|
||||||
{hasMultipleServers ? 'Do you want to ignore duplicated servers' : 'Do you want to save this server anyway'}?
|
<li>URL: <b>{url}</b></li>
|
||||||
</span>
|
<li>API key: <b>{apiKey}</b></li>
|
||||||
</ModalBody>
|
</Fragment>
|
||||||
<ModalFooter>
|
) : <li key={index}><b>{url}</b> - <b>{apiKey}</b></li>))}
|
||||||
<Button color="link" onClick={onDiscard}>{hasMultipleServers ? 'Ignore duplicates' : 'Discard'}</Button>
|
</ul>
|
||||||
<Button color="primary" onClick={onSave}>Save anyway</Button>
|
<span>
|
||||||
</ModalFooter>
|
{hasMultipleServers ? 'Do you want to save duplicated servers' : 'Do you want to save this server'}?
|
||||||
</Modal>
|
</span>
|
||||||
|
</CardModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
import { faFileUpload as importIcon } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
import { useElementRef, useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import { Button } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { ChangeEvent, PropsWithChildren } from 'react';
|
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef , useState } from 'react';
|
||||||
import { Button, UncontrolledTooltip } from 'reactstrap';
|
import { UncontrolledTooltip } from 'reactstrap';
|
||||||
import type { FCWithDeps } from '../../container/utils';
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../../container/utils';
|
import { componentFactory, useDependencies } from '../../container/utils';
|
||||||
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
import type { ServerData, ServersMap, ServerWithId } from '../data';
|
||||||
@@ -13,7 +14,7 @@ import { dedupServers, ensureUniqueIds } from './index';
|
|||||||
|
|
||||||
export type ImportServersBtnProps = PropsWithChildren<{
|
export type ImportServersBtnProps = PropsWithChildren<{
|
||||||
onImport?: () => void;
|
onImport?: () => void;
|
||||||
onImportError?: (error: Error) => void;
|
onError?: (error: Error) => void;
|
||||||
tooltipPlacement?: 'top' | 'bottom';
|
tooltipPlacement?: 'top' | 'bottom';
|
||||||
className?: string;
|
className?: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -31,8 +32,8 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
createServers,
|
createServers,
|
||||||
servers,
|
servers,
|
||||||
children,
|
children,
|
||||||
onImport = () => {},
|
onImport,
|
||||||
onImportError = () => {},
|
onError = () => {},
|
||||||
tooltipPlacement = 'bottom',
|
tooltipPlacement = 'bottom',
|
||||||
className = '',
|
className = '',
|
||||||
}) => {
|
}) => {
|
||||||
@@ -40,50 +41,50 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
const ref = useElementRef<HTMLInputElement>();
|
const ref = useElementRef<HTMLInputElement>();
|
||||||
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
const [duplicatedServers, setDuplicatedServers] = useState<ServerData[]>([]);
|
||||||
const [isModalOpen,, showModal, hideModal] = useToggle();
|
const [isModalOpen,, showModal, hideModal] = useToggle();
|
||||||
|
const newServersCreatedRef = useRef(false);
|
||||||
|
|
||||||
const importedServersRef = useRef<ServerWithId[]>([]);
|
|
||||||
const newServersRef = useRef<ServerWithId[]>([]);
|
|
||||||
|
|
||||||
const create = useCallback((serversData: ServerWithId[]) => {
|
|
||||||
createServers(serversData);
|
|
||||||
onImport();
|
|
||||||
}, [createServers, onImport]);
|
|
||||||
const onFile = useCallback(
|
const onFile = useCallback(
|
||||||
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
async ({ target }: ChangeEvent<HTMLInputElement>) =>
|
||||||
serversImporter.importServersFromFile(target.files?.[0])
|
serversImporter.importServersFromFile(target.files?.[0])
|
||||||
.then((importedServers) => {
|
.then((importedServers) => {
|
||||||
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
const { duplicatedServers, newServers } = dedupServers(servers, importedServers);
|
||||||
|
|
||||||
importedServersRef.current = ensureUniqueIds(servers, importedServers);
|
// Immediately create new servers
|
||||||
newServersRef.current = ensureUniqueIds(servers, newServers);
|
newServersCreatedRef.current = newServers.length > 0;
|
||||||
|
createServers(ensureUniqueIds(servers, newServers));
|
||||||
|
|
||||||
if (duplicatedServers.length === 0) {
|
// For duplicated servers, ask for confirmation
|
||||||
create(importedServersRef.current);
|
if (duplicatedServers.length > 0) {
|
||||||
} else {
|
|
||||||
setDuplicatedServers(duplicatedServers);
|
setDuplicatedServers(duplicatedServers);
|
||||||
showModal();
|
showModal();
|
||||||
|
} else {
|
||||||
|
onImport?.();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// Reset file input after processing file
|
// Reset file input after processing file
|
||||||
(target as { value: string | null }).value = null;
|
(target as { value: string | null }).value = null;
|
||||||
})
|
})
|
||||||
.catch(onImportError),
|
.catch(onError),
|
||||||
[create, onImportError, servers, serversImporter, showModal],
|
[createServers, onError, onImport, servers, serversImporter, showModal],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAllServers = useCallback(() => {
|
const createDuplicatedServers = useCallback(() => {
|
||||||
create(importedServersRef.current);
|
createServers(ensureUniqueIds(servers, duplicatedServers));
|
||||||
hideModal();
|
hideModal();
|
||||||
}, [create, hideModal]);
|
onImport?.();
|
||||||
const createNonDuplicatedServers = useCallback(() => {
|
}, [createServers, duplicatedServers, hideModal, onImport, servers]);
|
||||||
create(newServersRef.current);
|
const discardDuplicatedServers = useCallback(() => {
|
||||||
hideModal();
|
hideModal();
|
||||||
}, [create, hideModal]);
|
// If duplicated servers were discarded but some non-duplicated servers were created, call onImport
|
||||||
|
if (newServersCreatedRef.current) {
|
||||||
|
onImport?.();
|
||||||
|
}
|
||||||
|
}, [hideModal, onImport]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button outline id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
<Button variant="secondary" id="importBtn" className={className} onClick={() => ref.current?.click()}>
|
||||||
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
<FontAwesomeIcon icon={importIcon} fixedWidth /> {children ?? 'Import from file'}
|
||||||
</Button>
|
</Button>
|
||||||
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
<UncontrolledTooltip placement={tooltipPlacement} target="importBtn">
|
||||||
@@ -93,18 +94,19 @@ const ImportServersBtn: FCWithDeps<ImportServersBtnConnectProps, ImportServersBt
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".csv"
|
accept=".csv"
|
||||||
className="d-none"
|
className="tw:hidden"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
ref={ref}
|
tabIndex={-1}
|
||||||
|
ref={ref as any /* TODO Remove After updating to React 19 */}
|
||||||
onChange={onFile}
|
onChange={onFile}
|
||||||
data-testid="csv-file-input"
|
data-testid="csv-file-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DuplicatedServersModal
|
<DuplicatedServersModal
|
||||||
isOpen={isModalOpen}
|
open={isModalOpen}
|
||||||
duplicatedServers={duplicatedServers}
|
duplicatedServers={duplicatedServers}
|
||||||
onDiscard={createNonDuplicatedServers}
|
onClose={discardDuplicatedServers}
|
||||||
onSave={createAllServers}
|
onConfirm={createDuplicatedServers}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
@import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base';
|
|
||||||
|
|
||||||
.server-error__container {
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-error__delete-btn {
|
|
||||||
color: $dangerColor;
|
|
||||||
font-weight: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-error__delete-btn:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Message } from '@shlinkio/shlink-frontend-kit';
|
import { Card, Message } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router';
|
||||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../../container/utils';
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
import { componentFactory, useDependencies } from '../../container/utils';
|
import { componentFactory, useDependencies } from '../../container/utils';
|
||||||
@@ -8,7 +8,6 @@ import type { SelectedServer, ServersMap } from '../data';
|
|||||||
import { isServerWithId } from '../data';
|
import { isServerWithId } from '../data';
|
||||||
import type { DeleteServerButtonProps } from '../DeleteServerButton';
|
import type { DeleteServerButtonProps } from '../DeleteServerButton';
|
||||||
import { ServersListGroup } from '../ServersListGroup';
|
import { ServersListGroup } from '../ServersListGroup';
|
||||||
import './ServerError.scss';
|
|
||||||
|
|
||||||
type ServerErrorProps = {
|
type ServerErrorProps = {
|
||||||
servers: ServersMap;
|
servers: ServersMap;
|
||||||
@@ -24,8 +23,8 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<NoMenuLayout>
|
<NoMenuLayout>
|
||||||
<div className="server-error__container flex-column">
|
<div className="tw:flex tw:flex-col tw:items-center tw:gap-y-4 tw:md:gap-y-8">
|
||||||
<Message className="w-100 mb-3 mb-md-5" type="error" fullWidth>
|
<Message className="tw:w-full tw:lg:w-[80%]" variant="error">
|
||||||
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
{!isServerWithId(selectedServer) && 'Could not find this Shlink server.'}
|
||||||
{isServerWithId(selectedServer) && (
|
{isServerWithId(selectedServer) && (
|
||||||
<>
|
<>
|
||||||
@@ -35,21 +34,21 @@ const ServerError: FCWithDeps<ServerErrorProps, ServerErrorDeps> = ({ servers, s
|
|||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<ServersListGroup servers={Object.values(servers)}>
|
<p className="tw:text-xl">
|
||||||
<p className="mb-md-3">
|
These are the Shlink servers currently configured. Choose one of
|
||||||
These are the Shlink servers currently configured. Choose one of
|
them or <Link to="/server/create">add a new one</Link>.
|
||||||
them or <Link to="/server/create">add a new one</Link>.
|
</p>
|
||||||
</p>
|
<Card className="tw:w-full tw:max-w-100 tw:overflow-hidden">
|
||||||
</ServersListGroup>
|
<ServersListGroup borderless servers={Object.values(servers)} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
{isServerWithId(selectedServer) && (
|
{isServerWithId(selectedServer) && (
|
||||||
<div className="container mt-3 mt-md-5">
|
<p className="tw:text-xl">
|
||||||
<p className="fs-5 fw-normal lh-sm">
|
Alternatively, if you think you may have misconfigured this server, you
|
||||||
Alternatively, if you think you may have miss-configured this server, you
|
can <DeleteServerButton server={selectedServer}>remove
|
||||||
can <DeleteServerButton server={selectedServer} className="server-error__delete-btn">remove it</DeleteServerButton> or
|
it</DeleteServerButton> or
|
||||||
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
<Link to={`/server/${selectedServer.id}/edit?reconnect=true`}>edit it</Link>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</NoMenuLayout>
|
</NoMenuLayout>
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { InputFormGroup, SimpleCard } from '@shlinkio/shlink-frontend-kit';
|
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
Details,
|
||||||
|
Label,
|
||||||
|
LabelledInput,
|
||||||
|
LabelledRevealablePasswordInput,
|
||||||
|
SimpleCard,
|
||||||
|
} from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
import type { FC, PropsWithChildren, ReactNode } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { handleEventPreventingDefault } from '../../utils/utils';
|
import { usePreventDefault } from '../../utils/utils';
|
||||||
import type { ServerData } from '../data';
|
import type { ServerData } from '../data';
|
||||||
|
|
||||||
type ServerFormProps = PropsWithChildren<{
|
type ServerFormProps = PropsWithChildren<{
|
||||||
@@ -14,17 +22,43 @@ export const ServerForm: FC<ServerFormProps> = ({ onSubmit, initialValues, child
|
|||||||
const [name, setName] = useState(initialValues?.name ?? '');
|
const [name, setName] = useState(initialValues?.name ?? '');
|
||||||
const [url, setUrl] = useState(initialValues?.url ?? '');
|
const [url, setUrl] = useState(initialValues?.url ?? '');
|
||||||
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
|
const [apiKey, setApiKey] = useState(initialValues?.apiKey ?? '');
|
||||||
const handleSubmit = handleEventPreventingDefault(() => onSubmit({ name, url, apiKey }));
|
const { flag: forwardCredentials, toggle: toggleForwardCredentials } = useToggle(
|
||||||
|
initialValues?.forwardCredentials ?? false,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const handleSubmit = usePreventDefault(() => onSubmit({ name, url, apiKey, forwardCredentials }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="server-form" name="serverForm" onSubmit={handleSubmit}>
|
<form name="serverForm" onSubmit={handleSubmit}>
|
||||||
<SimpleCard className="mb-3" title={title}>
|
<SimpleCard className="tw:mb-4" bodyClassName="tw:flex tw:flex-col tw:gap-y-3" title={title}>
|
||||||
<InputFormGroup value={name} onChange={setName}>Name</InputFormGroup>
|
<LabelledInput label="Name" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
<InputFormGroup type="url" value={url} onChange={setUrl}>URL</InputFormGroup>
|
<LabelledInput label="URL" type="url" value={url} onChange={(e) => setUrl(e.target.value)} required />
|
||||||
<InputFormGroup value={apiKey} onChange={setApiKey}>API key</InputFormGroup>
|
<LabelledRevealablePasswordInput
|
||||||
|
label="API key"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Details summary="Advanced options">
|
||||||
|
<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="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="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="tw:whitespace-nowrap">Access-Control-Allow-Origin</code> than <code>*</code>.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</Details>
|
||||||
</SimpleCard>
|
</SimpleCard>
|
||||||
|
|
||||||
<div className="text-end">{children}</div>
|
<div className="tw:flex tw:items-center tw:justify-end tw:gap-x-2">{children}</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Message } from '@shlinkio/shlink-frontend-kit';
|
import { Message } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router';
|
||||||
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
import { NoMenuLayout } from '../../common/NoMenuLayout';
|
||||||
import type { FCWithDeps } from '../../container/utils';
|
import type { FCWithDeps } from '../../container/utils';
|
||||||
import { useDependencies } from '../../container/utils';
|
import { useDependencies } from '../../container/utils';
|
||||||
|
|||||||
@@ -2,24 +2,27 @@ import type { JsonToCsv } from '../../utils/helpers/csvjson';
|
|||||||
import { saveCsv } from '../../utils/helpers/files';
|
import { saveCsv } from '../../utils/helpers/files';
|
||||||
import type { LocalStorage } from '../../utils/services/LocalStorage';
|
import type { LocalStorage } from '../../utils/services/LocalStorage';
|
||||||
import type { ServersMap } from '../data';
|
import type { ServersMap } from '../data';
|
||||||
import { serverWithIdToServerData } from '../data';
|
import { serializeServer } from '../data';
|
||||||
|
|
||||||
const SERVERS_FILENAME = 'shlink-servers.csv';
|
const SERVERS_FILENAME = 'shlink-servers.csv';
|
||||||
|
|
||||||
export class ServersExporter {
|
export class ServersExporter {
|
||||||
public constructor(
|
readonly #storage: LocalStorage;
|
||||||
private readonly storage: LocalStorage,
|
readonly #window: Window;
|
||||||
private readonly window: Window,
|
readonly #jsonToCsv: JsonToCsv;
|
||||||
private readonly jsonToCsv: JsonToCsv,
|
|
||||||
) {}
|
public constructor(storage: LocalStorage, window: Window, jsonToCsv: JsonToCsv) {
|
||||||
|
this.#storage = storage;
|
||||||
|
this.#window = window;
|
||||||
|
this.#jsonToCsv = jsonToCsv;
|
||||||
|
}
|
||||||
|
|
||||||
public readonly exportServers = async () => {
|
public readonly exportServers = async () => {
|
||||||
const servers = Object.values(this.storage.get<ServersMap>('servers') ?? {}).map(serverWithIdToServerData);
|
const servers = Object.values(this.#storage.get<ServersMap>('servers') ?? {}).map(serializeServer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csv = this.jsonToCsv(servers);
|
const csv = this.#jsonToCsv(servers);
|
||||||
|
saveCsv(this.#window, csv, SERVERS_FILENAME);
|
||||||
saveCsv(this.window, csv, SERVERS_FILENAME);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// FIXME Handle error
|
// FIXME Handle error
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
import type { CsvToJson } from '../../utils/helpers/csvjson';
|
import type { CsvToJson } from '../../utils/helpers/csvjson';
|
||||||
import type { ServerData } from '../data';
|
import type { ServerData } from '../data';
|
||||||
|
import { deserializeServer } from '../data';
|
||||||
|
|
||||||
const validateServer = (server: any): server is ServerData =>
|
const validateAndDeserializeServers = (servers: unknown): ServerData[] => {
|
||||||
typeof server.url === 'string' && typeof server.apiKey === 'string' && typeof server.name === 'string';
|
if (!Array.isArray(servers)) {
|
||||||
|
throw new Error('Provided file does not have the right format.');
|
||||||
const validateServers = (servers: any): servers is ServerData[] =>
|
}
|
||||||
Array.isArray(servers) && servers.every(validateServer);
|
return servers.map(deserializeServer);
|
||||||
|
};
|
||||||
|
|
||||||
export class ServersImporter {
|
export class ServersImporter {
|
||||||
public constructor(private readonly csvToJson: CsvToJson) {}
|
readonly #csvToJson: CsvToJson;
|
||||||
|
|
||||||
|
public constructor(csvToJson: CsvToJson) {
|
||||||
|
this.#csvToJson = csvToJson;
|
||||||
|
}
|
||||||
|
|
||||||
public async importServersFromFile(file: File | null | undefined): Promise<ServerData[]> {
|
public async importServersFromFile(file: File | null | undefined): Promise<ServerData[]> {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
@@ -16,12 +22,8 @@ export class ServersImporter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const content = await file.text();
|
const content = await file.text();
|
||||||
const servers = await this.csvToJson(content);
|
const servers = await this.#csvToJson(content);
|
||||||
|
|
||||||
if (!validateServers(servers)) {
|
return validateAndDeserializeServers(servers);
|
||||||
throw new Error('Provided file does not have the right format.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return servers;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
src/tailwind.css
Normal file
23
src/tailwind.css
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@import 'tailwindcss' prefix(tw) important;
|
||||||
|
@source '../node_modules/@shlinkio/shlink-frontend-kit';
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { DropdownBtn } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import type { VisitsSettings } from '@shlinkio/shlink-web-component/settings';
|
|
||||||
import type { FC } from 'react';
|
|
||||||
import { DropdownItem } from 'reactstrap';
|
|
||||||
|
|
||||||
export type DateInterval = VisitsSettings['defaultInterval'];
|
|
||||||
|
|
||||||
export interface DateIntervalSelectorProps {
|
|
||||||
active?: DateInterval;
|
|
||||||
allText: string;
|
|
||||||
onChange: (interval: DateInterval) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const INTERVAL_TO_STRING_MAP: Record<Exclude<DateInterval, 'all'>, string> = {
|
|
||||||
today: 'Today',
|
|
||||||
yesterday: 'Yesterday',
|
|
||||||
last7Days: 'Last 7 days',
|
|
||||||
last30Days: 'Last 30 days',
|
|
||||||
last90Days: 'Last 90 days',
|
|
||||||
last180Days: 'Last 180 days',
|
|
||||||
last365Days: 'Last 365 days',
|
|
||||||
};
|
|
||||||
|
|
||||||
const intervalToString = (interval: DateInterval | undefined, fallback: string): string => {
|
|
||||||
if (!interval || interval === 'all') {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return INTERVAL_TO_STRING_MAP[interval];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DateIntervalSelector: FC<DateIntervalSelectorProps> = ({ onChange, active, allText }) => (
|
|
||||||
<DropdownBtn text={intervalToString(active, allText)}>
|
|
||||||
<DropdownItem active={active === 'all'} onClick={() => onChange('all')}>
|
|
||||||
{allText}
|
|
||||||
</DropdownItem>
|
|
||||||
<DropdownItem divider />
|
|
||||||
{Object.entries(INTERVAL_TO_STRING_MAP).map(
|
|
||||||
([interval, name]) => (
|
|
||||||
<DropdownItem key={interval} active={active === interval} onClick={() => onChange(interval as DateInterval)}>
|
|
||||||
{name}
|
|
||||||
</DropdownItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</DropdownBtn>
|
|
||||||
);
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
export const useGoBack = () => {
|
export const useGoBack = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
@mixin horizontal-align {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
@mixin thin-scroll() {
|
|
||||||
/* Forefox scrollbar */
|
|
||||||
scrollbar-color: rgba(0, 0, 0, .2) #f5f5f5;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
|
|
||||||
/* Chrome webkit scrollbar */
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(0, 0, 0, .2);
|
|
||||||
border-radius: .5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@mixin vertical-align($extraTransforms: null) {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%) $extraTransforms;
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { SyntheticEvent } from 'react';
|
import type { SyntheticEvent } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
export const handleEventPreventingDefault = <T>(handler: () => T) => (e: SyntheticEvent) => {
|
/**
|
||||||
e.preventDefault();
|
* Wraps an event handler so that it calls e.preventDefault() before invoking the event handler
|
||||||
handler();
|
*/
|
||||||
};
|
export const usePreventDefault = <Event extends SyntheticEvent = SyntheticEvent>(handler: (e: Event) => void) =>
|
||||||
|
useCallback((e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handler(e);
|
||||||
|
}, [handler]);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FC, PropsWithChildren } from 'react';
|
import type { FC, PropsWithChildren } from 'react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
import { MemoryRouter, Route, Routes } from 'react-router';
|
||||||
|
|
||||||
export type MemoryRouterWithParamsProps = PropsWithChildren<{
|
export type MemoryRouterWithParamsProps = PropsWithChildren<{
|
||||||
params: Record<string, string>;
|
params: Record<string, string>;
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
import { useToggle } from '@shlinkio/shlink-frontend-kit';
|
|
||||||
import type { FC, ReactElement } from 'react';
|
import type { FC, ReactElement } from 'react';
|
||||||
|
import { useCallback, useEffect , useState } from 'react';
|
||||||
|
|
||||||
interface RenderModalArgs {
|
export type RenderModalArgs = {
|
||||||
isOpen: boolean;
|
open: boolean;
|
||||||
toggle: () => void;
|
onClose: () => void;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
|
export const TestModalWrapper: FC<{ renderModal: (args: RenderModalArgs) => ReactElement }> = (
|
||||||
{ renderModal },
|
{ renderModal },
|
||||||
) => {
|
) => {
|
||||||
const [isOpen, toggle] = useToggle(true);
|
const [open, setOpen] = useState(true);
|
||||||
return renderModal({ isOpen, toggle });
|
const onClose = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
|
// Workaround to ensure CardModals from shlink-frontend-shared can be closed, as they depend on CSS transitions
|
||||||
|
// Since JSDOM does not support them, this dispatches the event right after the listener has been set-up
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
document.querySelector('[data-testid="transition-container"]')?.dispatchEvent(new Event('transitionend'));
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return renderModal({ open, onClose });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { HttpClient } from '@shlinkio/shlink-js-sdk';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder';
|
import { buildShlinkApiClient } from '../../../src/api/services/ShlinkApiClientBuilder';
|
||||||
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
|
import type { ReachableServer, SelectedServer } from '../../../src/servers/data';
|
||||||
@@ -5,8 +6,8 @@ import type { ReachableServer, SelectedServer } from '../../../src/servers/data'
|
|||||||
describe('ShlinkApiClientBuilder', () => {
|
describe('ShlinkApiClientBuilder', () => {
|
||||||
const server = fromPartial<ReachableServer>;
|
const server = fromPartial<ReachableServer>;
|
||||||
|
|
||||||
const createBuilder = () => {
|
const createBuilder = (httpClient: HttpClient = fromPartial({})) => {
|
||||||
const builder = buildShlinkApiClient(fromPartial({}));
|
const builder = buildShlinkApiClient(httpClient);
|
||||||
return (selectedServer: SelectedServer) => builder(() => fromPartial({ selectedServer }));
|
return (selectedServer: SelectedServer) => builder(() => fromPartial({ selectedServer }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,9 +17,9 @@ describe('ShlinkApiClientBuilder', () => {
|
|||||||
const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' }));
|
const secondApiClient = builder(server({ url: 'bar', apiKey: 'bar' }));
|
||||||
const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' }));
|
const thirdApiClient = builder(server({ url: 'bar', apiKey: 'foo' }));
|
||||||
|
|
||||||
expect(firstApiClient === secondApiClient).toEqual(false);
|
expect(firstApiClient).not.toBe(secondApiClient);
|
||||||
expect(firstApiClient === thirdApiClient).toEqual(false);
|
expect(firstApiClient).not.toBe(thirdApiClient);
|
||||||
expect(secondApiClient === thirdApiClient).toEqual(false);
|
expect(secondApiClient).not.toBe(thirdApiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns existing instances when provided params are the same', () => {
|
it('returns existing instances when provided params are the same', () => {
|
||||||
@@ -29,17 +30,42 @@ describe('ShlinkApiClientBuilder', () => {
|
|||||||
const secondApiClient = builder(selectedServer);
|
const secondApiClient = builder(selectedServer);
|
||||||
const thirdApiClient = builder(selectedServer);
|
const thirdApiClient = builder(selectedServer);
|
||||||
|
|
||||||
expect(firstApiClient === secondApiClient).toEqual(true);
|
expect(firstApiClient).toBe(secondApiClient);
|
||||||
expect(firstApiClient === thirdApiClient).toEqual(true);
|
expect(firstApiClient).toBe(thirdApiClient);
|
||||||
expect(secondApiClient === thirdApiClient).toEqual(true);
|
expect(secondApiClient).toBe(thirdApiClient);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not fetch from state when provided param is already selected server', () => {
|
it('does not fetch from state when provided param is already a server', async () => {
|
||||||
const url = 'url';
|
const url = 'the_url';
|
||||||
const apiKey = 'apiKey';
|
const apiKey = 'the_api_key';
|
||||||
const apiClient = buildShlinkApiClient(fromPartial({}))(server({ url, apiKey }));
|
const jsonRequest = vi.fn();
|
||||||
|
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
||||||
|
const apiClient = createBuilder(httpClient)(server({ url, apiKey }));
|
||||||
|
|
||||||
expect(apiClient['serverInfo'].baseUrl).toEqual(url);
|
await apiClient.health();
|
||||||
expect(apiClient['serverInfo'].apiKey).toEqual(apiKey);
|
|
||||||
|
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
|
||||||
|
credentials: undefined,
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes credentials when forwarding is enabled', async () => {
|
||||||
|
const url = 'the_url';
|
||||||
|
const apiKey = 'the_api_key';
|
||||||
|
const jsonRequest = vi.fn();
|
||||||
|
const httpClient = fromPartial<HttpClient>({ jsonRequest });
|
||||||
|
const apiClient = createBuilder(httpClient)(server({ url, apiKey, forwardCredentials: true }));
|
||||||
|
|
||||||
|
await apiClient.health();
|
||||||
|
|
||||||
|
expect(jsonRequest).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`^${url}`)), expect.objectContaining({
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'X-Api-Key': apiKey,
|
||||||
|
},
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { act, render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import { AppFactory } from '../../src/app/App';
|
import { AppFactory } from '../../src/app/App';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|
||||||
@@ -54,13 +54,17 @@ describe('<App />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['/foo', 'shlink-wrapper'],
|
['/foo', false],
|
||||||
['/bar', 'shlink-wrapper'],
|
['/bar', false],
|
||||||
['/', 'shlink-wrapper d-flex align-items-center pt-3'],
|
['/', true],
|
||||||
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, expectedClasses) => {
|
])('renders expected classes on shlink-wrapper based on current pathname', async (pathname, isFlex) => {
|
||||||
const { container } = await setUp(pathname);
|
await setUp(pathname);
|
||||||
const shlinkWrapper = container.querySelector('.shlink-wrapper');
|
const shlinkWrapper = screen.getByTestId('shlink-wrapper');
|
||||||
|
|
||||||
expect(shlinkWrapper).toHaveAttribute('class', expectedClasses);
|
if (isFlex) {
|
||||||
|
expect(shlinkWrapper).toHaveClass('tw:flex');
|
||||||
|
} else {
|
||||||
|
expect(shlinkWrapper).not.toHaveClass('tw:flex');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { checkAccessibility } from '../__helpers__/accessibility';
|
|||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<AppUpdateBanner />', () => {
|
describe('<AppUpdateBanner />', () => {
|
||||||
const toggle = vi.fn();
|
const onClose = vi.fn();
|
||||||
const forceUpdate = vi.fn();
|
const forceUpdate = vi.fn();
|
||||||
const setUp = async () => {
|
const setUp = async () => {
|
||||||
const result = await act(
|
const result = await act(
|
||||||
() => renderWithEvents(<AppUpdateBanner isOpen toggle={toggle} forceUpdate={forceUpdate} />),
|
() => renderWithEvents(<AppUpdateBanner isOpen onClose={onClose} forceUpdate={forceUpdate} />),
|
||||||
);
|
);
|
||||||
await waitFor(() => screen.getByRole('alert'));
|
await waitFor(() => screen.getByRole('alert'));
|
||||||
|
|
||||||
@@ -28,9 +28,9 @@ describe('<AppUpdateBanner />', () => {
|
|||||||
it('invokes toggle when alert is closed', async () => {
|
it('invokes toggle when alert is closed', async () => {
|
||||||
const { user } = await setUp();
|
const { user } = await setUp();
|
||||||
|
|
||||||
expect(toggle).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByLabelText('Close'));
|
await user.click(screen.getByLabelText('Close'));
|
||||||
expect(toggle).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('triggers the update when clicking the button', async () => {
|
it('triggers the update when clicking the button', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router';
|
||||||
import { MainHeaderFactory } from '../../src/common/MainHeader';
|
import { MainHeaderFactory } from '../../src/common/MainHeader';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
@@ -51,14 +51,12 @@ describe('<MainHeader />', () => {
|
|||||||
const toggle = screen.getByLabelText('Toggle navigation');
|
const toggle = screen.getByLabelText('Toggle navigation');
|
||||||
const icon = toggle.firstChild;
|
const icon = toggle.firstChild;
|
||||||
|
|
||||||
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/));
|
expect(icon).not.toHaveClass('tw:rotate-180');
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
expect(icon).toHaveAttribute(
|
|
||||||
'class',
|
expect(icon).toHaveClass('tw:rotate-180');
|
||||||
expect.stringMatching(/main-header__toggle-icon main-header__toggle-icon--opened$/),
|
|
||||||
);
|
|
||||||
await user.click(toggle);
|
await user.click(toggle);
|
||||||
expect(icon).toHaveAttribute('class', expect.stringMatching(/main-header__toggle-icon$/));
|
expect(icon).not.toHaveClass('tw:rotate-180');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens Collapse when clicking toggle', async () => {
|
it('opens Collapse when clicking toggle', async () => {
|
||||||
|
|||||||
@@ -30,6 +30,5 @@ describe('<NotFound />', () => {
|
|||||||
|
|
||||||
expect(link).toHaveAttribute('href', expectedLink);
|
expect(link).toHaveAttribute('href', expectedLink);
|
||||||
expect(link).toHaveTextContent(expectedText);
|
expect(link).toHaveTextContent(expectedText);
|
||||||
expect(link).toHaveAttribute('class', 'btn btn-outline-primary btn-lg');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,11 +16,16 @@ describe('<ShlinkVersionsContainer />', () => {
|
|||||||
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer)));
|
])('passes a11y checks', (selectedServer) => checkAccessibility(setUp(selectedServer)));
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[null, 'text-center'],
|
[null, false],
|
||||||
[fromPartial<SelectedServer>({}), 'text-center'],
|
[fromPartial<SelectedServer>({}), false],
|
||||||
[fromPartial<ReachableServer>({ version: '1.0.0' }), 'text-center shlink-versions-container--with-sidebar'],
|
[fromPartial<ReachableServer>({ version: '1.0.0' }), true],
|
||||||
])('renders proper col classes based on sidebar status', (selectedServer, expectedClasses) => {
|
])('renders proper col classes based on sidebar status', (selectedServer, shouldAddMargin) => {
|
||||||
const { container } = setUp(selectedServer);
|
const { container } = setUp(selectedServer);
|
||||||
expect(container.firstChild).toHaveAttribute('class', `${expectedClasses}`);
|
|
||||||
|
if (shouldAddMargin) {
|
||||||
|
expect(container.firstChild).toHaveClass('tw:md:ml-(--aside-menu-width)');
|
||||||
|
} else {
|
||||||
|
expect(container.firstChild).not.toHaveClass('tw:md:ml-(--aside-menu-width)');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
import { fireEvent, screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router';
|
||||||
import { CreateServerFactory } from '../../src/servers/CreateServer';
|
import { CreateServerFactory } from '../../src/servers/CreateServer';
|
||||||
import type { ServersMap } from '../../src/servers/data';
|
import type { ServersMap } from '../../src/servers/data';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
|
import { createMemoryHistory } from 'history';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Router } from 'react-router';
|
||||||
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
|
import { DeleteServerButtonFactory } from '../../src/servers/DeleteServerButton';
|
||||||
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
|
import type { DeleteServerModalProps } from '../../src/servers/DeleteServerModal';
|
||||||
|
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DeleteServerButton />', () => {
|
describe('<DeleteServerButton />', () => {
|
||||||
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
|
const DeleteServerButton = DeleteServerButtonFactory(fromPartial({
|
||||||
DeleteServerModal: ({ isOpen }: DeleteServerModalProps) => <>DeleteServerModal {isOpen ? '[Open]' : '[Closed]'}</>,
|
DeleteServerModal: (props: DeleteServerModalProps) => <DeleteServerModal {...props} deleteServer={vi.fn()} />,
|
||||||
}));
|
}));
|
||||||
const setUp = (children?: ReactNode) => renderWithEvents(
|
const setUp = (children: ReactNode = 'Remove this server') => {
|
||||||
<DeleteServerButton server={fromPartial({})} textClassName="button">{children}</DeleteServerButton>,
|
const history = createMemoryHistory({ initialEntries: ['/foo'] });
|
||||||
);
|
const result = renderWithEvents(
|
||||||
|
<Router location={history.location} navigator={history}>
|
||||||
|
<DeleteServerButton server={fromPartial({})}>{children}</DeleteServerButton>
|
||||||
|
</Router>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { history, ...result };
|
||||||
|
};
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
|
it('passes a11y checks', () => checkAccessibility(setUp('Delete me')));
|
||||||
|
|
||||||
@@ -20,7 +30,6 @@ describe('<DeleteServerButton />', () => {
|
|||||||
['Foo bar'],
|
['Foo bar'],
|
||||||
['baz'],
|
['baz'],
|
||||||
['something'],
|
['something'],
|
||||||
[undefined],
|
|
||||||
])('renders expected content', (children) => {
|
])('renders expected content', (children) => {
|
||||||
const { container } = setUp(children);
|
const { container } = setUp(children);
|
||||||
expect(container.firstChild).toBeTruthy();
|
expect(container.firstChild).toBeTruthy();
|
||||||
@@ -28,14 +37,21 @@ describe('<DeleteServerButton />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('displays modal when button is clicked', async () => {
|
it('displays modal when button is clicked', async () => {
|
||||||
const { user, container } = setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Closed/);
|
expect(screen.queryByText(/Are you sure you want to remove/)).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/DeleteServerModal/)).not.toHaveTextContent(/Open/);
|
await user.click(screen.getByText('Remove this server'));
|
||||||
if (container.firstElementChild) {
|
expect(screen.getByText(/Are you sure you want to remove/)).toBeInTheDocument();
|
||||||
await user.click(container.firstElementChild);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText(/DeleteServerModal/)).toHaveTextContent(/Open/));
|
it('navigates to home when deletion is confirmed', async () => {
|
||||||
|
const { user, history } = setUp();
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
await user.click(screen.getByText('Remove this server'));
|
||||||
|
|
||||||
|
expect(history.location.pathname).toEqual('/foo');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
expect(history.location.pathname).toEqual('/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { act, screen, waitFor } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
|
||||||
import { Router } from 'react-router-dom';
|
|
||||||
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
import { DeleteServerModal } from '../../src/servers/DeleteServerModal';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
@@ -10,36 +8,29 @@ import { TestModalWrapper } from '../__helpers__/TestModalWrapper';
|
|||||||
describe('<DeleteServerModal />', () => {
|
describe('<DeleteServerModal />', () => {
|
||||||
const deleteServerMock = vi.fn();
|
const deleteServerMock = vi.fn();
|
||||||
const serverName = 'the_server_name';
|
const serverName = 'the_server_name';
|
||||||
const setUp = async () => {
|
const setUp = () => renderWithEvents(
|
||||||
const history = createMemoryHistory({ initialEntries: ['/foo'] });
|
<TestModalWrapper
|
||||||
const result = await act(() => renderWithEvents(
|
renderModal={(args) => (
|
||||||
<Router location={history.location} navigator={history}>
|
<DeleteServerModal
|
||||||
<TestModalWrapper
|
{...args}
|
||||||
renderModal={(args) => (
|
server={fromPartial({ name: serverName })}
|
||||||
<DeleteServerModal
|
deleteServer={deleteServerMock}
|
||||||
{...args}
|
|
||||||
server={fromPartial({ name: serverName })}
|
|
||||||
deleteServer={deleteServerMock}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</Router>,
|
)}
|
||||||
));
|
/>,
|
||||||
|
);
|
||||||
return { history, ...result };
|
|
||||||
};
|
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
it('renders a modal window', async () => {
|
it('renders a modal window', () => {
|
||||||
await setUp();
|
setUp();
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading')).toHaveTextContent('Remove server');
|
expect(screen.getByRole('heading')).toHaveTextContent('Remove server');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('displays the name of the server as part of the content', async () => {
|
it('displays the name of the server as part of the content', () => {
|
||||||
await setUp();
|
setUp();
|
||||||
|
|
||||||
expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument();
|
expect(screen.getByText(/^Are you sure you want to remove/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(serverName)).toBeInTheDocument();
|
expect(screen.getByText(serverName)).toBeInTheDocument();
|
||||||
@@ -47,25 +38,21 @@ describe('<DeleteServerModal />', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[() => screen.getByRole('button', { name: 'Cancel' })],
|
[() => screen.getByRole('button', { name: 'Cancel' })],
|
||||||
[() => screen.getByLabelText('Close')],
|
[() => screen.getByLabelText('Close dialog')],
|
||||||
])('toggles when clicking cancel button', async (getButton) => {
|
])('closes dialog when clicking cancel button', async (getButton) => {
|
||||||
const { user, history } = await setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(history.location.pathname).toEqual('/foo');
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
await user.click(getButton());
|
await user.click(getButton());
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
expect(deleteServerMock).not.toHaveBeenCalled();
|
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||||
expect(history.location.pathname).toEqual('/foo'); // No navigation happens, keeping initial pathname
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes server when clicking accept button', async () => {
|
it('deletes server when clicking accept button', async () => {
|
||||||
const { user, history } = await setUp();
|
const { user } = setUp();
|
||||||
|
|
||||||
expect(deleteServerMock).not.toHaveBeenCalled();
|
expect(deleteServerMock).not.toHaveBeenCalled();
|
||||||
expect(history.location.pathname).toEqual('/foo');
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||||
|
expect(deleteServerMock).toHaveBeenCalledOnce();
|
||||||
await waitFor(() => expect(deleteServerMock).toHaveBeenCalledTimes(1));
|
|
||||||
await waitFor(() => expect(history.location.pathname).toEqual('/'));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fireEvent, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router';
|
||||||
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import type { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { EditServerFactory } from '../../src/servers/EditServer';
|
import { EditServerFactory } from '../../src/servers/EditServer';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
@@ -47,16 +47,16 @@ describe('<EditServer />', () => {
|
|||||||
it('display the server info in the form components', () => {
|
it('display the server info in the form components', () => {
|
||||||
setUp();
|
setUp();
|
||||||
|
|
||||||
expect(screen.getByDisplayValue('the_name')).toBeInTheDocument();
|
expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue('the_url')).toBeInTheDocument();
|
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
|
||||||
expect(screen.getByDisplayValue('the_api_key')).toBeInTheDocument();
|
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits server and redirects to it when form is submitted', async () => {
|
it('edits server and redirects to it when form is submitted', async () => {
|
||||||
const { user, history } = setUp();
|
const { user, history } = setUp();
|
||||||
|
|
||||||
await user.type(screen.getByDisplayValue('the_name'), ' edited');
|
await user.type(screen.getByLabelText(/^Name/), ' edited');
|
||||||
await user.type(screen.getByDisplayValue('the_url'), ' edited');
|
await user.type(screen.getByLabelText(/^URL/), ' edited');
|
||||||
// TODO Using fire event because userEvent.click on the Submit button does not submit the form
|
// TODO Using fire event because userEvent.click on the Submit button does not submit the form
|
||||||
// await user.click(screen.getByRole('button', { name: 'Save' }));
|
// await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||||
fireEvent.submit(screen.getByRole('form'));
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
@@ -65,9 +65,26 @@ describe('<EditServer />', () => {
|
|||||||
name: 'the_name edited',
|
name: 'the_name edited',
|
||||||
url: 'the_url edited',
|
url: 'the_url edited',
|
||||||
apiKey: 'the_api_key',
|
apiKey: 'the_api_key',
|
||||||
|
forwardCredentials: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// After saving we go back, to the first route from history's initialEntries
|
// After saving we go back, to the first route from history's initialEntries
|
||||||
expect(history.location.pathname).toEqual('/foo');
|
expect(history.location.pathname).toEqual('/foo');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ forwardCredentials: true },
|
||||||
|
{ forwardCredentials: false },
|
||||||
|
])('edits advanced options - forward credentials', async (serverPartial) => {
|
||||||
|
const { user } = setUp({ ...defaultSelectedServer, ...serverPartial });
|
||||||
|
|
||||||
|
await user.click(screen.getByText('Advanced options'));
|
||||||
|
await user.click(screen.getByLabelText('Forward credentials to this server on every request.'));
|
||||||
|
|
||||||
|
fireEvent.submit(screen.getByRole('form'));
|
||||||
|
|
||||||
|
expect(editServerMock).toHaveBeenCalledWith('abc123', expect.objectContaining({
|
||||||
|
forwardCredentials: !serverPartial.forwardCredentials,
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
import type { ServersMap, ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServersFactory } from '../../src/servers/ManageServers';
|
import { ManageServersFactory } from '../../src/servers/ManageServers';
|
||||||
import type { ServersExporter } from '../../src/servers/services/ServersExporter';
|
import type { ServersExporter } from '../../src/servers/services/ServersExporter';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Table } from '@shlinkio/shlink-frontend-kit/tailwind';
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
|
import { ManageServersRowFactory } from '../../src/servers/ManageServersRow';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
@@ -17,11 +18,9 @@ describe('<ManageServersRow />', () => {
|
|||||||
};
|
};
|
||||||
const setUp = (hasAutoConnect = false, autoConnect = false) => render(
|
const setUp = (hasAutoConnect = false, autoConnect = false) => render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<table>
|
<Table header={<Table.Row />}>
|
||||||
<tbody>
|
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
||||||
<ManageServersRow server={{ ...server, autoConnect }} hasAutoConnect={hasAutoConnect} />
|
</Table>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,11 +31,7 @@ describe('<ManageServersRow />', () => {
|
|||||||
[false, 3],
|
[false, 3],
|
||||||
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
|
])('renders expected amount of columns', (hasAutoConnect, expectedCols) => {
|
||||||
setUp(hasAutoConnect);
|
setUp(hasAutoConnect);
|
||||||
|
expect(screen.getAllByRole('cell')).toHaveLength(expectedCols);
|
||||||
const td = screen.getAllByRole('cell');
|
|
||||||
const th = screen.getAllByRole('columnheader');
|
|
||||||
|
|
||||||
expect(td.length + th.length).toEqual(expectedCols);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a dropdown', () => {
|
it('renders a dropdown', () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import type { UserEvent } from '@testing-library/user-event';
|
import type { UserEvent } from '@testing-library/user-event';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServerWithId } from '../../src/servers/data';
|
||||||
import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
|
import { ManageServersRowDropdownFactory } from '../../src/servers/ManageServersRowDropdown';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
@@ -9,8 +9,8 @@ import { renderWithEvents } from '../__helpers__/setUpTest';
|
|||||||
|
|
||||||
describe('<ManageServersRowDropdown />', () => {
|
describe('<ManageServersRowDropdown />', () => {
|
||||||
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
|
const ManageServersRowDropdown = ManageServersRowDropdownFactory(fromPartial({
|
||||||
DeleteServerModal: ({ isOpen }: { isOpen: boolean }) => (
|
DeleteServerModal: ({ open }: { open: boolean }) => (
|
||||||
<span>DeleteServerModal {isOpen ? '[OPEN]' : '[CLOSED]'}</span>
|
<span>DeleteServerModal {open ? '[OPEN]' : '[CLOSED]'}</span>
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
const setAutoConnect = vi.fn();
|
const setAutoConnect = vi.fn();
|
||||||
@@ -24,15 +24,13 @@ describe('<ManageServersRowDropdown />', () => {
|
|||||||
};
|
};
|
||||||
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
|
const toggleDropdown = (user: UserEvent) => user.click(screen.getByRole('button'));
|
||||||
|
|
||||||
it.each([
|
it('passes a11y checks', async () => {
|
||||||
[setUp],
|
const { user, container } = setUp();
|
||||||
[async () => {
|
// Open menu
|
||||||
const { user, container } = setUp();
|
await toggleDropdown(user);
|
||||||
await toggleDropdown(user);
|
|
||||||
|
|
||||||
return { container };
|
return checkAccessibility({ container });
|
||||||
}],
|
});
|
||||||
])('passes a11y checks', (setUp) => checkAccessibility(setUp()));
|
|
||||||
|
|
||||||
it('renders expected amount of dropdown items', async () => {
|
it('renders expected amount of dropdown items', async () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServersMap } from '../../src/servers/data';
|
import type { ServersMap } from '../../src/servers/data';
|
||||||
import { ServersDropdown } from '../../src/servers/ServersDropdown';
|
import { ServersDropdown } from '../../src/servers/ServersDropdown';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
@@ -20,7 +20,13 @@ describe('<ServersDropdown />', () => {
|
|||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', async () => {
|
||||||
|
const { user, ...rest } = setUp();
|
||||||
|
// Open menu
|
||||||
|
await user.click(screen.getByText('Servers'));
|
||||||
|
|
||||||
|
return checkAccessibility(rest);
|
||||||
|
});
|
||||||
|
|
||||||
it('contains the list of servers and the "mange servers" button', async () => {
|
it('contains the list of servers and the "mange servers" button', async () => {
|
||||||
const { user } = setUp();
|
const { user } = setUp();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { ServerWithId } from '../../src/servers/data';
|
import type { ServerWithId } from '../../src/servers/data';
|
||||||
import { ServersListGroup } from '../../src/servers/ServersListGroup';
|
import { ServersListGroup } from '../../src/servers/ServersListGroup';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
@@ -10,30 +10,18 @@ describe('<ServersListGroup />', () => {
|
|||||||
fromPartial({ name: 'foo', id: '123' }),
|
fromPartial({ name: 'foo', id: '123' }),
|
||||||
fromPartial({ name: 'bar', id: '456' }),
|
fromPartial({ name: 'bar', id: '456' }),
|
||||||
];
|
];
|
||||||
const setUp = (params: { servers?: ServerWithId[]; withChildren?: boolean; embedded?: boolean } = {}) => {
|
const setUp = (params: { servers?: ServerWithId[]; borderless?: boolean } = {}) => {
|
||||||
const { servers = [], withChildren = true, embedded } = params;
|
const { servers = [], borderless } = params;
|
||||||
|
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter>
|
<MemoryRouter>
|
||||||
<ServersListGroup servers={servers} embedded={embedded}>
|
<ServersListGroup servers={servers} borderless={borderless} />
|
||||||
{withChildren ? 'The list of servers' : undefined}
|
|
||||||
</ServersListGroup>
|
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
it('renders title', () => {
|
|
||||||
setUp({});
|
|
||||||
expect(screen.getByTestId('title')).toHaveTextContent('The list of servers');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not render title when children is not provided', () => {
|
|
||||||
setUp({ withChildren: false });
|
|
||||||
expect(screen.queryByTestId('title')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[servers],
|
[servers],
|
||||||
[[]],
|
[[]],
|
||||||
@@ -45,11 +33,17 @@ describe('<ServersListGroup />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[true, 'servers-list__list-group servers-list__list-group--embedded'],
|
[true],
|
||||||
[false, 'servers-list__list-group'],
|
[false],
|
||||||
[undefined, 'servers-list__list-group'],
|
[undefined],
|
||||||
])('renders proper classes for embedded', (embedded, expectedClasses) => {
|
])('renders proper classes for embedded', (borderless) => {
|
||||||
setUp({ servers, embedded });
|
setUp({ servers, borderless });
|
||||||
expect(screen.getByTestId('list')).toHaveAttribute('class', `${expectedClasses} list-group`);
|
const list = screen.getByTestId('list');
|
||||||
|
|
||||||
|
if (!borderless) {
|
||||||
|
expect(list).toHaveClass('tw:border-y');
|
||||||
|
} else {
|
||||||
|
expect(list).not.toHaveClass('tw:border-y');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,67 +2,27 @@
|
|||||||
|
|
||||||
exports[`<DeleteServerButton /> > renders expected content 1`] = `
|
exports[`<DeleteServerButton /> > renders expected content 1`] = `
|
||||||
<button
|
<button
|
||||||
class="p-0 bg-transparent border-0"
|
class="tw:text-danger tw:hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
Foo bar
|
||||||
class="button"
|
|
||||||
>
|
|
||||||
Foo bar
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<DeleteServerButton /> > renders expected content 2`] = `
|
exports[`<DeleteServerButton /> > renders expected content 2`] = `
|
||||||
<button
|
<button
|
||||||
class="p-0 bg-transparent border-0"
|
class="tw:text-danger tw:hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
baz
|
||||||
class="button"
|
|
||||||
>
|
|
||||||
baz
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`<DeleteServerButton /> > renders expected content 3`] = `
|
exports[`<DeleteServerButton /> > renders expected content 3`] = `
|
||||||
<button
|
<button
|
||||||
class="p-0 bg-transparent border-0"
|
class="tw:text-danger tw:hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<span
|
something
|
||||||
class="button"
|
|
||||||
>
|
|
||||||
something
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
|
|
||||||
exports[`<DeleteServerButton /> > renders expected content 4`] = `
|
|
||||||
<button
|
|
||||||
class="p-0 bg-transparent border-0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-circle-minus fa-fw "
|
|
||||||
data-icon="circle-minus"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
class="button"
|
|
||||||
>
|
|
||||||
Remove this server
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,18 +2,29 @@
|
|||||||
|
|
||||||
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = `
|
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 1`] = `
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table
|
||||||
<tbody>
|
class="tw:w-full"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="tw:hidden tw:lg:table-header-group"
|
||||||
|
>
|
||||||
<tr
|
<tr
|
||||||
class="responsive-table__row"
|
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
|
||||||
|
/>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
|
||||||
data-th="Auto-connect"
|
data-column="Auto-connect"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="svg-inline--fa fa-check text-primary"
|
class="svg-inline--fa fa-check tw:text-brand"
|
||||||
data-icon="check"
|
data-icon="check"
|
||||||
data-prefix="fas"
|
data-prefix="fas"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
@@ -28,24 +39,25 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</td>
|
</td>
|
||||||
<th
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
|
||||||
data-th="Name"
|
data-column="Name"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
data-discover="true"
|
||||||
href="/server/abc"
|
href="/server/abc"
|
||||||
>
|
>
|
||||||
My server
|
My server
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
|
||||||
data-th="Base URL"
|
data-column="Base URL"
|
||||||
>
|
>
|
||||||
https://example.com
|
https://example.com
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell text-end"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
ManageServersRowDropdown
|
ManageServersRowDropdown
|
||||||
@@ -59,33 +71,45 @@ exports[`<ManageServersRow /> > renders auto-connect icon only if server is auto
|
|||||||
|
|
||||||
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 2`] = `
|
exports[`<ManageServersRow /> > renders auto-connect icon only if server is autoConnect 2`] = `
|
||||||
<div>
|
<div>
|
||||||
<table>
|
<table
|
||||||
<tbody>
|
class="tw:w-full"
|
||||||
|
>
|
||||||
|
<thead
|
||||||
|
class="tw:hidden tw:lg:table-header-group"
|
||||||
|
>
|
||||||
<tr
|
<tr
|
||||||
class="responsive-table__row"
|
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border"
|
||||||
|
/>
|
||||||
|
</thead>
|
||||||
|
<tbody
|
||||||
|
class="tw:lg:table-row-group tw:flex tw:flex-col tw:gap-y-3"
|
||||||
|
>
|
||||||
|
<tr
|
||||||
|
class="tw:group tw:lg:table-row tw:flex tw:flex-col tw:lg:border-0 tw:border-y-2 tw:border-lm-border tw:dark:border-dm-border tw:hover:bg-lm-primary tw:dark:hover:bg-dm-primary tw:group-[&]/card:hover:bg-lm-secondary tw:dark:group-[&]/card:hover:bg-dm-secondary tw:relative"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1"
|
||||||
data-th="Auto-connect"
|
data-column="Auto-connect"
|
||||||
/>
|
/>
|
||||||
<th
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:font-bold"
|
||||||
data-th="Name"
|
data-column="Name"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
data-discover="true"
|
||||||
href="/server/abc"
|
href="/server/abc"
|
||||||
>
|
>
|
||||||
My server
|
My server
|
||||||
</a>
|
</a>
|
||||||
</th>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:max-lg:border-b-0"
|
||||||
data-th="Base URL"
|
data-column="Base URL"
|
||||||
>
|
>
|
||||||
https://example.com
|
https://example.com
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
class="responsive-table__cell text-end"
|
class="tw:p-2 tw:border-lm-border tw:dark:border-dm-border tw:block tw:lg:table-cell tw:not-last:border-b-1 tw:lg:border-b-1 tw:before:lg:hidden tw:before:content-[attr(data-column)] tw:before:font-bold tw:before:mr-1 tw:text-right tw:max-lg:absolute tw:right-0 tw:-top-1 tw:mx-lg:pt-0"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
ManageServersRowDropdown
|
ManageServersRowDropdown
|
||||||
|
|||||||
@@ -35,111 +35,12 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 1`] = `
|
|||||||
role="menu"
|
role="menu"
|
||||||
style="min-width: 210px;"
|
style="min-width: 210px;"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
/>
|
||||||
<a
|
|
||||||
class="dropdown-item"
|
|
||||||
href="/server/abc123"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-plug fa-fw "
|
|
||||||
data-icon="plug"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 384 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M96 0C78.3 0 64 14.3 64 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 32c0 77.4 55 142 128 156.8l0 67.2c0 17.7 14.3 32 32 32s32-14.3 32-32l0-67.2C297 398 352 333.4 352 256l0-32c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 160z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Connect
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="dropdown-item"
|
|
||||||
href="/server/abc123/edit"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-pen-to-square fa-fw "
|
|
||||||
data-icon="pen-to-square"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M471.6 21.7c-21.9-21.9-57.3-21.9-79.2 0L362.3 51.7l97.9 97.9 30.1-30.1c21.9-21.9 21.9-57.3 0-79.2L471.6 21.7zm-299.2 220c-6.1 6.1-10.8 13.6-13.5 21.9l-29.6 88.8c-2.9 8.6-.6 18.1 5.8 24.6s15.9 8.7 24.6 5.8l88.8-29.6c8.2-2.7 15.7-7.4 21.9-13.5L437.7 172.3 339.7 74.3 172.4 241.7zM96 64C43 64 0 107 0 160L0 416c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 64z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Edit server
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
class="dropdown-item"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-ban fa-fw "
|
|
||||||
data-icon="ban"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M367.2 412.5L99.5 144.8C77.1 176.1 64 214.5 64 256c0 106 86 192 192 192c41.5 0 79.9-13.1 111.2-35.5zm45.3-45.3C434.9 335.9 448 297.5 448 256c0-106-86-192-192-192c-41.5 0-79.9 13.1-111.2 35.5L412.5 367.2zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
Do not a
|
|
||||||
uto-connect
|
|
||||||
</button>
|
|
||||||
<hr
|
|
||||||
class="dropdown-divider"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="dropdown-item--danger dropdown-item"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-circle-minus fa-fw "
|
|
||||||
data-icon="circle-minus"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remove server
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
DeleteServerModal
|
|
||||||
[CLOSED]
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span>
|
||||||
|
DeleteServerModal
|
||||||
|
[CLOSED]
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -178,110 +79,11 @@ exports[`<ManageServersRowDropdown /> > renders expected size and icon 2`] = `
|
|||||||
role="menu"
|
role="menu"
|
||||||
style="min-width: 170px;"
|
style="min-width: 170px;"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
/>
|
||||||
<a
|
|
||||||
class="dropdown-item"
|
|
||||||
href="/server/abc123"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-plug fa-fw "
|
|
||||||
data-icon="plug"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 384 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M96 0C78.3 0 64 14.3 64 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM288 0c-17.7 0-32 14.3-32 32l0 96 64 0 0-96c0-17.7-14.3-32-32-32zM32 160c-17.7 0-32 14.3-32 32s14.3 32 32 32l0 32c0 77.4 55 142 128 156.8l0 67.2c0 17.7 14.3 32 32 32s32-14.3 32-32l0-67.2C297 398 352 333.4 352 256l0-32c17.7 0 32-14.3 32-32s-14.3-32-32-32L32 160z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Connect
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
class="dropdown-item"
|
|
||||||
href="/server/abc123/edit"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-pen-to-square fa-fw "
|
|
||||||
data-icon="pen-to-square"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M471.6 21.7c-21.9-21.9-57.3-21.9-79.2 0L362.3 51.7l97.9 97.9 30.1-30.1c21.9-21.9 21.9-57.3 0-79.2L471.6 21.7zm-299.2 220c-6.1 6.1-10.8 13.6-13.5 21.9l-29.6 88.8c-2.9 8.6-.6 18.1 5.8 24.6s15.9 8.7 24.6 5.8l88.8-29.6c8.2-2.7 15.7-7.4 21.9-13.5L437.7 172.3 339.7 74.3 172.4 241.7zM96 64C43 64 0 107 0 160L0 416c0 53 43 96 96 96l256 0c53 0 96-43 96-96l0-96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 96c0 17.7-14.3 32-32 32L96 448c-17.7 0-32-14.3-32-32l0-256c0-17.7 14.3-32 32-32l96 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L96 64z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Edit server
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
class="dropdown-item"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-circle fa-fw "
|
|
||||||
data-icon="circle"
|
|
||||||
data-prefix="far"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M464 256A208 208 0 1 0 48 256a208 208 0 1 0 416 0zM0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
A
|
|
||||||
uto-connect
|
|
||||||
</button>
|
|
||||||
<hr
|
|
||||||
class="dropdown-divider"
|
|
||||||
tabindex="-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
class="dropdown-item--danger dropdown-item"
|
|
||||||
role="menuitem"
|
|
||||||
tabindex="0"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
class="svg-inline--fa fa-circle-minus fa-fw "
|
|
||||||
data-icon="circle-minus"
|
|
||||||
data-prefix="fas"
|
|
||||||
focusable="false"
|
|
||||||
role="img"
|
|
||||||
viewBox="0 0 512 512"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM184 232l144 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-144 0c-13.3 0-24-10.7-24-24s10.7-24 24-24z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remove server
|
|
||||||
</button>
|
|
||||||
<span>
|
|
||||||
DeleteServerModal
|
|
||||||
[CLOSED]
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span>
|
||||||
|
DeleteServerModal
|
||||||
|
[CLOSED]
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import { checkAccessibility } from '../../__helpers__/accessibility';
|
|||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DuplicatedServersModal />', () => {
|
describe('<DuplicatedServersModal />', () => {
|
||||||
const onDiscard = vi.fn();
|
const onClose = vi.fn();
|
||||||
const onSave = vi.fn();
|
const onConfirm = vi.fn();
|
||||||
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
|
const setUp = (duplicatedServers: ServerData[] = []) => act(() => renderWithEvents(
|
||||||
<DuplicatedServersModal isOpen duplicatedServers={duplicatedServers} onDiscard={onDiscard} onSave={onSave} />,
|
<DuplicatedServersModal open duplicatedServers={duplicatedServers} onClose={onClose} onConfirm={onConfirm} />,
|
||||||
));
|
));
|
||||||
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
const mockServer = (data: Partial<ServerData> = {}) => fromPartial<ServerData>(data);
|
||||||
|
|
||||||
@@ -32,8 +32,9 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
{
|
{
|
||||||
header: 'Duplicated server',
|
header: 'Duplicated server',
|
||||||
firstParagraph: 'There is already a server with:',
|
firstParagraph: 'There is already a server with:',
|
||||||
lastParagraph: 'Do you want to save this server anyway?',
|
lastParagraph: 'Do you want to save this server?',
|
||||||
discardBtn: 'Discard',
|
discardBtn: 'Discard',
|
||||||
|
confirmButton: 'Save duplicate',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -41,8 +42,9 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
{
|
{
|
||||||
header: 'Duplicated servers',
|
header: 'Duplicated servers',
|
||||||
firstParagraph: 'The next servers already exist:',
|
firstParagraph: 'The next servers already exist:',
|
||||||
lastParagraph: 'Do you want to ignore duplicated servers?',
|
lastParagraph: 'Do you want to save duplicated servers?',
|
||||||
discardBtn: 'Ignore duplicates',
|
discardBtn: 'Ignore duplicates',
|
||||||
|
confirmButton: 'Save duplicates',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
|
])('renders expected texts based on amount of servers', async (duplicatedServers, assertions) => {
|
||||||
@@ -52,6 +54,7 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
|
expect(screen.getByText(assertions.firstParagraph)).toBeInTheDocument();
|
||||||
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
|
expect(screen.getByText(assertions.lastParagraph)).toBeInTheDocument();
|
||||||
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: assertions.discardBtn })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: assertions.confirmButton })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -80,19 +83,19 @@ describe('<DuplicatedServersModal />', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes onDiscard when appropriate button is clicked', async () => {
|
it('invokes onClose when appropriate button is clicked', async () => {
|
||||||
const { user } = await setUp();
|
const { user } = await setUp();
|
||||||
|
|
||||||
expect(onDiscard).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
await user.click(screen.getByRole('button', { name: 'Discard' }));
|
||||||
expect(onDiscard).toHaveBeenCalled();
|
expect(onClose).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes onSave when appropriate button is clicked', async () => {
|
it('invokes onConfirm when appropriate button is clicked', async () => {
|
||||||
const { user } = await setUp();
|
const { user } = await setUp();
|
||||||
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
await user.click(screen.getByRole('button', { name: 'Save anyway' }));
|
await user.click(screen.getByRole('button', { name: 'Save duplicate' }));
|
||||||
expect(onSave).toHaveBeenCalled();
|
expect(onConfirm).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ describe('<ImportServersBtn />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
{ btnName: 'Save anyway',savesDuplicatedServers: true },
|
{ btnName: 'Save duplicate', savesDuplicatedServers: true },
|
||||||
{ btnName: 'Discard', savesDuplicatedServers: false },
|
{ btnName: 'Discard', savesDuplicatedServers: false },
|
||||||
])('creates expected servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
])('creates duplicated servers depending on selected option in modal', async ({ btnName, savesDuplicatedServers }) => {
|
||||||
const existingServerData: ServerData = {
|
const existingServerData: ServerData = {
|
||||||
name: 'existingServer',
|
name: 'existingServer',
|
||||||
url: 'http://s.test/existingUrl',
|
url: 'http://s.test/existingUrl',
|
||||||
@@ -84,14 +84,20 @@ describe('<ImportServersBtn />', () => {
|
|||||||
|
|
||||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
await user.upload(screen.getByTestId('csv-file-input'), csvFile);
|
||||||
|
|
||||||
|
// Once the file is uploaded, non-duplicated servers are immediately created
|
||||||
|
expect(createServersMock).toHaveBeenCalledExactlyOnceWith([expect.objectContaining(newServer)]);
|
||||||
|
|
||||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
await user.click(screen.getByRole('button', { name: btnName }));
|
await user.click(screen.getByRole('button', { name: btnName }));
|
||||||
|
|
||||||
expect(createServersMock).toHaveBeenCalledWith(
|
// If duplicated servers are saved, there's one extra call
|
||||||
savesDuplicatedServers
|
if (savesDuplicatedServers) {
|
||||||
? [expect.objectContaining(existingServerData), expect.objectContaining(newServer)]
|
expect(createServersMock).toHaveBeenLastCalledWith([expect.objectContaining(existingServerData)]);
|
||||||
: [expect.objectContaining(newServer)],
|
}
|
||||||
);
|
|
||||||
expect(onImportMock).toHaveBeenCalledTimes(1);
|
// On import is called only once, no matter what
|
||||||
|
expect(onImportMock).toHaveBeenCalledOnce();
|
||||||
|
expect(createServersMock).toHaveBeenCalledTimes(savesDuplicatedServers ? 2 : 1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
|
import type { NonReachableServer, NotFoundServer, SelectedServer } from '../../../src/servers/data';
|
||||||
import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
|
import { ServerErrorFactory } from '../../../src/servers/helpers/ServerError';
|
||||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||||
@@ -26,7 +26,7 @@ describe('<ServerError />', () => {
|
|||||||
notFound: [
|
notFound: [
|
||||||
'Oops! Could not connect to this Shlink server.',
|
'Oops! Could not connect to this Shlink server.',
|
||||||
'Make sure you have internet connection, and the server is properly configured and on-line.',
|
'Make sure you have internet connection, and the server is properly configured and on-line.',
|
||||||
/^Alternatively, if you think you may have miss-configured this server/,
|
/^Alternatively, if you think you may have misconfigured this server/,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -36,7 +36,7 @@ describe('<ServerError />', () => {
|
|||||||
found: [
|
found: [
|
||||||
'Oops! Could not connect to this Shlink server.',
|
'Oops! Could not connect to this Shlink server.',
|
||||||
'Make sure you have internet connection, and the server is properly configured and on-line.',
|
'Make sure you have internet connection, and the server is properly configured and on-line.',
|
||||||
/^Alternatively, if you think you may have miss-configured this server/,
|
/^Alternatively, if you think you may have misconfigured this server/,
|
||||||
],
|
],
|
||||||
notFound: ['Could not find this Shlink server.'],
|
notFound: ['Could not find this Shlink server.'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
import { fireEvent, screen } from '@testing-library/react';
|
||||||
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
|
import { ServerForm } from '../../../src/servers/helpers/ServerForm';
|
||||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
import { checkAccessibility } from '../../__helpers__/accessibility';
|
||||||
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<ServerForm />', () => {
|
describe('<ServerForm />', () => {
|
||||||
const onSubmit = vi.fn();
|
const onSubmit = vi.fn();
|
||||||
const setUp = () => render(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
|
const setUp = () => renderWithEvents(<ServerForm onSubmit={onSubmit}>Something</ServerForm>);
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
it('passes a11y checks', () => checkAccessibility(setUp()));
|
||||||
|
|
||||||
it('renders components', () => {
|
it('renders inputs', () => {
|
||||||
setUp();
|
setUp();
|
||||||
|
|
||||||
expect(screen.getAllByRole('textbox')).toHaveLength(3);
|
expect(screen.getByLabelText(/^Name/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/^URL/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/^API key/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Something')).toBeInTheDocument();
|
expect(screen.getByText('Something')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Advanced options')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('invokes submit callback when submit event is triggered', async () => {
|
it('invokes submit callback when submit event is triggered', async () => {
|
||||||
@@ -22,4 +26,13 @@ describe('<ServerForm />', () => {
|
|||||||
fireEvent.submit(screen.getByRole('form'), { preventDefault: vi.fn() });
|
fireEvent.submit(screen.getByRole('form'), { preventDefault: vi.fn() });
|
||||||
expect(onSubmit).toHaveBeenCalled();
|
expect(onSubmit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows advanced options', async () => {
|
||||||
|
const { user } = setUp();
|
||||||
|
const forwardCredentialsLabel = 'Forward credentials to this server on every request.';
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText(forwardCredentialsLabel)).not.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByText('Advanced options'));
|
||||||
|
expect(screen.getByLabelText(forwardCredentialsLabel)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,45 +1,49 @@
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
|
import type { ServersMap } from '../../../src/servers/data';
|
||||||
|
import { serializeServer } from '../../../src/servers/data';
|
||||||
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
|
import { ServersExporter } from '../../../src/servers/services/ServersExporter';
|
||||||
import type { LocalStorage } from '../../../src/utils/services/LocalStorage';
|
import type { LocalStorage } from '../../../src/utils/services/LocalStorage';
|
||||||
import { appendChild, removeChild, windowMock } from '../../__mocks__/Window.mock';
|
import { appendChild, removeChild, windowMock } from '../../__mocks__/Window.mock';
|
||||||
|
|
||||||
describe('ServersExporter', () => {
|
describe('ServersExporter', () => {
|
||||||
|
const servers: ServersMap = {
|
||||||
|
abc123: {
|
||||||
|
id: 'abc123',
|
||||||
|
name: 'foo',
|
||||||
|
url: 'https://foo.com',
|
||||||
|
apiKey: 'foo_api_key',
|
||||||
|
autoConnect: true,
|
||||||
|
},
|
||||||
|
def456: {
|
||||||
|
id: 'def456',
|
||||||
|
name: 'bar',
|
||||||
|
url: 'https://bar.com',
|
||||||
|
apiKey: 'bar_api_key',
|
||||||
|
forwardCredentials: true,
|
||||||
|
autoConnect: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
const storageMock = fromPartial<LocalStorage>({
|
const storageMock = fromPartial<LocalStorage>({
|
||||||
get: vi.fn(() => ({
|
get: vi.fn(() => servers as any),
|
||||||
abc123: {
|
|
||||||
id: 'abc123',
|
|
||||||
name: 'foo',
|
|
||||||
autoConnect: true,
|
|
||||||
},
|
|
||||||
def456: {
|
|
||||||
id: 'def456',
|
|
||||||
name: 'bar',
|
|
||||||
autoConnect: false,
|
|
||||||
},
|
|
||||||
} as any)),
|
|
||||||
});
|
});
|
||||||
const erroneousToCsv = vi.fn(() => {
|
const erroneousToCsv = vi.fn(() => {
|
||||||
throw new Error('');
|
throw new Error('');
|
||||||
});
|
});
|
||||||
const createCsvjsonMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
|
const createJsonToCsvMock = (throwError = false) => (throwError ? erroneousToCsv : vi.fn(() => ''));
|
||||||
|
|
||||||
describe('exportServers', () => {
|
describe('exportServers', () => {
|
||||||
let originalConsole: Console;
|
|
||||||
const error = vi.fn();
|
const error = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalConsole = global.console;
|
vi.stubGlobal('console', fromPartial<Console>({ error }));
|
||||||
global.console = fromPartial<Console>({ error });
|
|
||||||
(global as any).Blob = class Blob {};
|
|
||||||
(global as any).URL = { createObjectURL: () => '' };
|
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
global.console = originalConsole;
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('logs an error if something fails', () => {
|
it('logs an error if something fails', () => {
|
||||||
const csvjsonMock = createCsvjsonMock(true);
|
const jsonToCsvMock = createJsonToCsvMock(true);
|
||||||
const exporter = new ServersExporter(storageMock, windowMock, csvjsonMock);
|
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
|
||||||
|
|
||||||
exporter.exportServers();
|
exporter.exportServers();
|
||||||
|
|
||||||
@@ -48,7 +52,8 @@ describe('ServersExporter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('makes use of download link API', () => {
|
it('makes use of download link API', () => {
|
||||||
const exporter = new ServersExporter(storageMock, windowMock, createCsvjsonMock());
|
const jsonToCsvMock = createJsonToCsvMock();
|
||||||
|
const exporter = new ServersExporter(storageMock, windowMock, jsonToCsvMock);
|
||||||
const { document: { createElement } } = windowMock;
|
const { document: { createElement } } = windowMock;
|
||||||
|
|
||||||
exporter.exportServers();
|
exporter.exportServers();
|
||||||
@@ -57,6 +62,7 @@ describe('ServersExporter', () => {
|
|||||||
expect(createElement).toHaveBeenCalledTimes(1);
|
expect(createElement).toHaveBeenCalledTimes(1);
|
||||||
expect(appendChild).toHaveBeenCalledTimes(1);
|
expect(appendChild).toHaveBeenCalledTimes(1);
|
||||||
expect(removeChild).toHaveBeenCalledTimes(1);
|
expect(removeChild).toHaveBeenCalledTimes(1);
|
||||||
|
expect(jsonToCsvMock).toHaveBeenCalledWith(Object.values(servers).map(serializeServer));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fromPartial } from '@total-typescript/shoehorn';
|
import { fromPartial } from '@total-typescript/shoehorn';
|
||||||
import type { RegularServer } from '../../../src/servers/data';
|
import type { RegularServer, ServerData } from '../../../src/servers/data';
|
||||||
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
import { ServersImporter } from '../../../src/servers/services/ServersImporter';
|
||||||
|
|
||||||
describe('ServersImporter', () => {
|
describe('ServersImporter', () => {
|
||||||
@@ -25,20 +25,24 @@ describe('ServersImporter', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{}],
|
{ parsedObject: {}, expectedError: 'Provided file does not have the right format.' },
|
||||||
[undefined],
|
{ parsedObject: undefined, expectedError: 'Provided file does not have the right format.' },
|
||||||
[[{ foo: 'bar' }]],
|
{
|
||||||
[
|
parsedObject: [{ foo: 'bar' }],
|
||||||
[
|
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
parsedObject: [
|
||||||
{
|
{
|
||||||
url: 1,
|
url: 1,
|
||||||
apiKey: 1,
|
apiKey: 1,
|
||||||
name: 1,
|
name: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||||
[
|
},
|
||||||
[
|
{
|
||||||
|
parsedObject: [
|
||||||
{
|
{
|
||||||
url: 'foo',
|
url: 'foo',
|
||||||
apiKey: 'foo',
|
apiKey: 'foo',
|
||||||
@@ -46,26 +50,29 @@ describe('ServersImporter', () => {
|
|||||||
},
|
},
|
||||||
{ bar: 'foo' },
|
{ bar: 'foo' },
|
||||||
],
|
],
|
||||||
],
|
expectedError: 'Server is missing required "url", "apiKey" and/or "name" properties',
|
||||||
])('rejects with error if provided file does not parse to valid list of servers', async (parsedObject) => {
|
},
|
||||||
|
])('rejects with error if provided file does not parse to valid list of servers', async ({
|
||||||
|
parsedObject,
|
||||||
|
expectedError,
|
||||||
|
}) => {
|
||||||
csvjsonMock.mockResolvedValue(parsedObject);
|
csvjsonMock.mockResolvedValue(parsedObject);
|
||||||
|
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(new Error(expectedError));
|
||||||
await expect(importer.importServersFromFile(fileMock())).rejects.toEqual(
|
|
||||||
new Error('Provided file does not have the right format.'),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads file when a CSV containing valid servers is provided', async () => {
|
it('reads file when a CSV containing valid servers is provided', async () => {
|
||||||
const expectedServers = [
|
const expectedServers: Required<ServerData>[] = [
|
||||||
{
|
{
|
||||||
url: 'foo',
|
url: 'foo',
|
||||||
apiKey: 'foo',
|
apiKey: 'foo',
|
||||||
name: 'foo',
|
name: 'foo',
|
||||||
|
forwardCredentials: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: 'bar',
|
url: 'bar',
|
||||||
apiKey: 'bar',
|
apiKey: 'bar',
|
||||||
name: 'bar',
|
name: 'bar',
|
||||||
|
forwardCredentials: false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router';
|
||||||
import { Settings } from '../../src/settings/Settings';
|
import { Settings } from '../../src/settings/Settings';
|
||||||
import { checkAccessibility } from '../__helpers__/accessibility';
|
import { checkAccessibility } from '../__helpers__/accessibility';
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
|
||||||
import type { DateInterval } from '../../../src/utils/dates/DateIntervalSelector';
|
|
||||||
import { DateIntervalSelector, INTERVAL_TO_STRING_MAP } from '../../../src/utils/dates/DateIntervalSelector';
|
|
||||||
import { checkAccessibility } from '../../__helpers__/accessibility';
|
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
|
||||||
|
|
||||||
describe('<DateIntervalSelector />', () => {
|
|
||||||
const activeInterval: DateInterval = 'last7Days';
|
|
||||||
const onChange = vi.fn();
|
|
||||||
const setUp = () => renderWithEvents(
|
|
||||||
<DateIntervalSelector allText="All text" active={activeInterval} onChange={onChange} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
it('passes a11y checks', () => checkAccessibility(setUp()));
|
|
||||||
|
|
||||||
it('passes props down to nested DateIntervalDropdownItems', async () => {
|
|
||||||
const { user } = setUp();
|
|
||||||
const btn = screen.getByRole('button');
|
|
||||||
|
|
||||||
await user.click(btn);
|
|
||||||
await waitFor(() => expect(screen.getByRole('menu')).toBeInTheDocument());
|
|
||||||
|
|
||||||
const items = screen.getAllByRole('menuitem');
|
|
||||||
|
|
||||||
expect(btn).toHaveTextContent(INTERVAL_TO_STRING_MAP[activeInterval] ?? '');
|
|
||||||
expect(items).toHaveLength(8);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
import { manifest } from './manifest';
|
import { manifest } from './manifest';
|
||||||
import pack from './package.json';
|
import pack from './package.json' with { type: 'json' };
|
||||||
|
|
||||||
|
const DEFAULT_NODE_VERSION = 'v22.10.0';
|
||||||
|
const nodeVersion = process.version ?? DEFAULT_NODE_VERSION;
|
||||||
const homepage = pack.homepage?.trim();
|
const homepage = pack.homepage?.trim();
|
||||||
|
|
||||||
/* eslint-disable-next-line no-restricted-exports */
|
/* eslint-disable-next-line no-restricted-exports */
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), VitePWA({
|
plugins: [react(), tailwindcss(), VitePWA({
|
||||||
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
mode: process.env.NODE_ENV === 'development' ? 'development' : 'production',
|
||||||
strategies: 'injectManifest',
|
strategies: 'injectManifest',
|
||||||
srcDir: './src',
|
srcDir: './src',
|
||||||
@@ -22,14 +26,24 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
watch: {
|
||||||
|
// Do not watch test files or generated files, avoiding the dev server to constantly reload when not needed
|
||||||
|
ignored: ['**/.idea/**', '**/.git/**', '**/build/**', '**/coverage/**', '**/test/**'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
|
base: !homepage ? undefined : homepage, // Not using just homepage because empty string should be discarded
|
||||||
|
|
||||||
// Vitest config
|
// Vitest config
|
||||||
test: {
|
test: {
|
||||||
|
// Run tests in an actual browser
|
||||||
|
browser: {
|
||||||
|
provider: 'playwright',
|
||||||
|
enabled: true,
|
||||||
|
headless: true,
|
||||||
|
screenshotFailures: false,
|
||||||
|
instances: [{ browser: 'chromium' }],
|
||||||
|
},
|
||||||
globals: true,
|
globals: true,
|
||||||
allowOnly: true,
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: './config/test/setupTests.ts',
|
setupFiles: './config/test/setupTests.ts',
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
@@ -46,10 +60,23 @@ export default defineConfig({
|
|||||||
// Required code coverage. Lower than this will make the check fail
|
// Required code coverage. Lower than this will make the check fail
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 95,
|
statements: 95,
|
||||||
branches: 90,
|
branches: 95,
|
||||||
functions: 90,
|
functions: 95,
|
||||||
lines: 95,
|
lines: 95,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Silent warnings triggered by reactstrap components, as it's getting removed
|
||||||
|
onConsoleLog: (log) => !log.includes('`transition.timeout` is marked as required'),
|
||||||
|
|
||||||
|
// Workaround for bug in react-router (or vitest module resolution) which causes different react-router versions to
|
||||||
|
// be resolved for the main package and dependencies who have a peer dependency in react-router.
|
||||||
|
// This ensures always the same version is resolved.
|
||||||
|
// See https://github.com/remix-run/react-router/issues/12785 for details
|
||||||
|
alias: nodeVersion > DEFAULT_NODE_VERSION
|
||||||
|
? {
|
||||||
|
'react-router': resolve(__dirname, 'node_modules/react-router/dist/development/index.mjs'),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user